URL encoding: + vs %20, whether ; splits parameters, and why a lone % throws
URL encoding is not hard; it is position-dependent. The same space can be written three ways, a + is a space in one part of a URL and a literal plus in another, and a % sitting in your data stops the decoder cold. Which is why the useful question is never “is this character encoded correctly” but “which segment of the URL is it sitting in”.
Percent-encoding does one thing: write bytes as %XX
The rule is mechanical — expand the character to UTF-8 bytes, then write each byte as % plus two hex digits (conventionally uppercase). Which gives you:
- One Han character becomes 9 characters. It is 3 bytes in UTF-8 →
%E4%B8%AD. - One emoji becomes 12 characters. It is 4 bytes →
%F0%9F%98%80, and in JavaScript its.lengthis 2 (a UTF-16 surrogate pair). Any loop that builds bytes fromcharCodeAtcan split that pair and encode the halves separately, producing an invalid sequence the other end cannot decode. - The characters left alone are
A-Za-z0-9-._~, the RFC 3986 unreserved set. Everything else must be encoded, or it will be read as structure.
encodeURIComponent additionally leaves !'()* alone — a leftover from RFC 2396; the current standard says they should be encoded. So when your output and a “strict RFC 3986” tool differ by exactly those five characters, nothing is broken. Both forms decode correctly server-side, but if you are comparing strings to build a signature you must pin one convention.
encodeURI vs encodeURIComponent: whether structure survives
encodeURI("a?b=c&d") // "a?b=c&d" — nothing touched
encodeURIComponent("a?b=c&d") // "a%3Fb%3Dc%26d"
encodeURI exists for “I have a whole link and only want the spaces and non-ASCII escaped”: it preserves the 14 structural characters :/?#[]@!$&'()*+,;=. That is exactly why it must never be used on a parameter value. An unencoded & in a value becomes a new parameter (?next=/a&b=1 hands the server a b you never meant to send), an unencoded # truncates everything after it into a fragment the server never sees, and an unencoded = confuses naive parsers.
One line to remember: encodeURI for whole links, encodeURIComponent for values — or better, build the URL with a URL object and let searchParams.set() do the escaping.
Spaces: %20, +, and “raw” is never fine
The value a b appears three ways depending on who produced it:
| Where it comes from | Output | Typical producer |
|---|---|---|
encodeURIComponent("a b") |
a%20b |
hand-written query strings in JS |
new URLSearchParams({ q: "a b" }).toString() |
q=a+b |
HTML forms, URLSearchParams |
| a space typed into a link | https://a.com/a%20b |
the browser normalising it |
+ means a space only inside the query string:
new URL("https://a.com/?q=a+b").searchParams.get("q") // "a b" — space
new URL("https://a.com/a+b").pathname // "/a+b" — plus sign
This is the most common production bug in the whole area: a password, token or filename containing + is concatenated into a query string, and the server decodes a space. If signature verification fails for some users only, check whether their values contain + — especially when the value is Base64 to begin with (see Base64 with Chinese and emoji, the + and / section). The reverse also holds: a literal plus inside a query belongs on the wire as %2B, and the URL encoder / decoder and Base64 tool are the two places to check it character by character.
; is not a separator; repeated keys need getAll
[...new URL("https://a.com/?a=1;b=2").searchParams] // [["a", "1;b=2"]]
Browsers treat the whole thing as one parameter’s value — ; is an ordinary character. Legacy code that separated parameters with semicolons (some frameworks, some PHP configurations) ends up with one strange value under the current parsing rules. Two parameters means two &.
Repeated keys, by contrast, are legal and common:
const p = new URL("https://a.com/?t=1&t=2").searchParams;
p.get("t"); // "1" — silently the first only
p.getAll("t"); // ["1", "2"]
get never tells you that more values exist, so “my parameter lost a value” is usually a get vs getAll mismatch on the receiving side.
% is the one character that cannot appear in data
decodeURIComponent("100%") throws URIError, and so does decodeURIComponent("100% pure milk") — a % must be followed by two hex digits. Real data loves percent signs, %PDF headers, %s placeholders.
More subtle: a broken UTF-8 sequence like %C3%28 is syntactically perfect (two well-formed escapes) but is not a valid character in any encoding, and the browser throws URIError on it. “It decoded” and “the input was correct” are different statements.
Double encoding is detectable by eye: decode %25E4%25B8%25AD once and you get %E4%B8%AD — if a complete %XX shape survives a decode pass, another pass is owed. %25 is simply an encoded %. encodeURIComponent(encodeURIComponent(" ")) yields %2520; decoded once on the server it becomes the three-character string %20, which looks almost right and is not.
A pasted link has already been rewritten
Every row below was measured in Chrome. These are normalisations defined by the URL standard, not failures:
| You pasted | The browser reads | Why |
|---|---|---|
https://a.com/../../../../etc/passwd |
https://a.com/etc/passwd |
dot segments are folded |
https:\a.com/x (one backslash) |
https://a.com/x |
in http/https, \ is treated as / |
https://a.com:443/p |
port is empty | 443 is https’ default, so it disappears |
ht⇥tps://a.com/ (tab inside) |
https://a.com/ |
tabs, newlines and surrounding spaces are removed |
https://a.com/a b |
https://a.com/a%20b |
the space is percent-encoded for you |
https://例例.中国/a |
hostname xn--fsqa.xn--fiqs8s |
IDNA conversion — international domains are punycode on the wire |
https://a.com:99999/ |
parse error | out-of-range port, new URL() throws TypeError |
To know what will actually be requested, read the normalised href, not the text you typed. Two caveats: folding .. makes the syntax tidy, it says nothing about whether the path is safe to serve — that is the server’s problem; and the fragment after # is never sent to the server, which is why putting a secret there only hides it from logs while leaving it in browser history and readable by every script the document loads.
Checking your own strings
The URL encoder / decoder splits a link into scheme, host, port, path, query and fragment and flattens the query into a parameter table (showing both the single-value and all-values read). Its second half percent-encodes or decodes any text and reports how many characters were escaped and how many escapes were invalid: a lone % is kept literally and counted, a broken UTF-8 sequence becomes a replacement character and is counted. So “decoded” with a non-zero count means you should go look at the source. The options expose the exact ambiguous points — form-style +, the encodeURI character set, and !'()* — so the result can be matched against your own code character by character.
Related questions
Why does my encoded string differ from another site’s? Three likely reasons: + instead of %20 for spaces; whether !'()* were escaped; and hex case (%e4 and %E4 are equivalent to a decoder, unequal to a string comparison). Pin one convention before comparing for signatures.
Must I encode non-ASCII in the path? Browsers do it for you when the URL is parsed, but the set of characters that must be encoded differs per segment — / is structure in the path, & is structure in the query. Manual concatenation is where it goes wrong; assembling with URL + searchParams.set() is the safe pattern.
Is form encoding the same as percent encoding? No. application/x-www-form-urlencoded writes spaces as + and escapes the rest over UTF-8; RFC 3986 percent-encoding writes %20. Both are in production, and the only difference that matters is which decoder the server uses — hence a checkbox rather than auto-detection.
Does a pasted link get uploaded? No. Parsing uses the browser’s own URL object and the escaping is a loop in the script; the link is never handed to anybody else, so what you type never leaves this device.