β 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
| Context | Space becomes | Spec |
|---|---|---|
| URL path | %20 | RFC 3986 |
| Query string (form-urlencoded) | + | WHATWG URL / HTML5 |
| Fragment (#...) | %20 | RFC 3986 |
| HTTP header values | literal space, often quoted | RFC 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
- Receiving a URL from a user β assume encoded, decode once to display.
- Building a URL programmatically β encode each piece with
encodeURIComponent, then concatenate. - Storing a URL in a database β store the encoded form. It is the canonical representation.
- Comparing two URLs β normalize both (lowercase scheme/host, decode unreserved chars, sort query params), then compare.
Tools
- URL Encoder / Decoder β round-trip any string, see the bytes
- URL Parser β split scheme, host, path, query, fragment
- Query String Builder β encode parameter objects safely
- HTML Entities β the other web encoding scheme
- Base64 β when percent-encoding is too verbose for binary