← Blog Β· May 23, 2026 Β· dev, web

URL Encoding 2026: When Percent-Encoding Bites You

Percent-encoding is the rule that any byte outside the 66-character "unreserved" set in URLs must be written as %HH where HH is its hex value β€” and every layer of the web disagrees about exactly which characters that means. RFC 3986 defines the canonical set. application/x-www-form-urlencoded defines a different one.encodeURI and encodeURIComponent in JavaScript disagree with both. That is where the bugs come from.

The numbers

Per RFC 3986 section 2.3, exactly 66 characters are unreserved and never need encoding: A-Z (26), a-z (26), 0-9 (10), plus - _ . ~ (4). Everything else falls into "reserved" (used as delimiters) or "other" (must be percent-encoded). A space becomes %20β€” or +, depending on which spec is in charge.

The four encoding contexts you actually meet

ContextSpace becomesSpec
URL path%20RFC 3986
Query string (form-urlencoded)+WHATWG URL / HTML5
Fragment (#...)%20RFC 3986
HTTP header valuesliteral space, often quotedRFC 7230

This is the bug that hits hardest β€” + in a URL path is a literal plus sign, but+ in a query string is a space. Decode ?q=foo+bar and you getfoo bar. Decode /foo+bar and you get foo+bar. Same character, two meanings, one URL.

encodeURI vs encodeURIComponent β€” pick wrong and you break things

JavaScript ships two encoders. They do different jobs.

encodeURI('https://example.com/path with spaces?q=a&b=c')
// "https://example.com/path%20with%20spaces?q=a&b=c"
// Does NOT encode :  /  ?  #  [  ]  @  !  $  &  '  (  )  *  +  ,  ;  =

encodeURIComponent('https://example.com/path with spaces?q=a&b=c')
// "https%3A%2F%2Fexample.com%2Fpath%20with%20spaces%3Fq%3Da%26b%3Dc"
// Encodes everything except A-Z a-z 0-9 - _ . ! ~ * ' ( )

Rule of thumb: encodeURI for a whole URL you trust the structure of.encodeURIComponent for a single piece you are inserting into a URL β€” a query value, a path segment, a fragment fragment. Use the wrong one and a user query like?next=/foo?x=1 silently breaks because the inner ? wasn't escaped.

Round-trip any string with our URL encoder to see exactly which bytes change.

The five characters that bite most

Character   Encoded   Why it bites
  space      %20 or +   Two encodings, context-dependent
  &          %26        Delimits query params β€” unencoded = data loss
  #          %23        Starts the fragment β€” anything after is dropped server-side
  +          %2B        Means space in form-urlencoded β€” literal + is lost
  /          %2F        Path delimiter β€” unencoded inside a value breaks routing

UTF-8 first, then percent-encode the bytes

Modern percent-encoding works on bytes, not characters. To encode a non-ASCII character you first encode it as UTF-8, then percent-encode each byte. The emoji πŸ˜€is 4 UTF-8 bytes (F0 9F 98 80) and percent-encodes to %F0%9F%98%80β€” twelve characters for one glyph. This is why pasting a Japanese URL into a logger produces a wall of %E3%81%82 sequences.

encodeURIComponent('cafΓ©')      // "caf%C3%A9"  β€” 'Γ©' is 2 UTF-8 bytes
encodeURIComponent('ζ—₯本θͺž')    // "%E6%97%A5%E6%9C%AC%E8%AA%9E"
encodeURIComponent('πŸ˜€')        // "%F0%9F%98%80"

Double encoding β€” the silent disaster

If you encode a string twice, you get a string that decodes once back to an encoded form, not the original. encodeURIComponent('a b') is a%20b. Encoding that again gives a%2520b β€” the % got encoded as %25. Servers that decode once then route based on the result will fail in confusing ways.

Symptom: URL params that work in dev break in prod, and the value contains %25where you expected nothing. Cause: a proxy or framework decoded once and the application decoded again. Fix: decode exactly once, at the boundary closest to the user.

When to decode, when to leave alone

  1. Receiving a URL from a user β€” assume encoded, decode once to display.
  2. Building a URL programmatically β€” encode each piece with encodeURIComponent, then concatenate.
  3. Storing a URL in a database β€” store the encoded form. It is the canonical representation.
  4. Comparing two URLs β€” normalize both (lowercase scheme/host, decode unreserved chars, sort query params), then compare.

Tools

← All blog posts