URL Encoding: Why Spaces Become %20 and + Causes Bugs
Somewhere in every codebase there is a bug where a user's email address arrives with the plus stripped out. [email protected] becomes maria [email protected], the lookup fails, and nobody can reproduce it because nobody on the team uses plus-addressing.
The cause is that URLs are governed by two encoding rules that agree about everything except one character.
Why encoding exists at all
A URL is not free text. Certain characters are structural: ? starts the query, & separates parameters, = splits name from value, / separates path segments, # begins the fragment. Others cannot travel safely — a space would end the URL in an old HTTP request line, and non-ASCII characters have no agreed byte representation.
Percent-encoding solves both. Take the character's bytes and write each as % followed by two hex digits. A space is byte 0x20, so it becomes %20. é in UTF-8 is two bytes, 0xC3 0xA9, so it becomes %C3%A9.
Once encoded, the character is inert. %26 inside a value cannot be mistaken for the & that separates parameters, which is the entire point.
Where the plus comes from
Percent-encoding is defined by RFC 3986 and applies to URLs generally. Under it, a space is %20, always.
But HTML forms do not use RFC 3986 for their query strings. They use application/x-www-form-urlencoded, a format that predates it, and which encodes a space as +.
So both of these mean "hello world":
?q=hello%20world
?q=hello+world
And here is the consequence: under form encoding, a literal plus must be written %2B, because a bare + already means space. Under RFC 3986, a bare + is just a plus.
The same string decodes differently depending on which rule the decoder follows. That is the whole bug.
The email address case
[email protected] submitted through a form is correctly encoded as:
maria%2Bbilling%40example.com
If some layer encodes it with RFC 3986 rules instead, the plus is not special, so it travels literally:
[email protected]
Now a form-encoding decoder reads that bare + as a space and hands your application maria [email protected]. Nothing errored. A character silently changed meaning between two components that were each behaving correctly.
You can watch this happen in an encoder/decoder: encode a plus, decode it back under both interpretations, and the divergence is obvious in about ten seconds.
Which JavaScript function to use
The platform gives you three, and they differ in what they consider safe to leave alone.
encodeURIComponent — encodes everything except A-Z a-z 0-9 - _ . ! ~ * ' ( ). It encodes &, =, ?, / and #. This is what you want for a single parameter value.
encodeURI — leaves the structural characters intact because it expects a whole URL. Use it to make an already-assembled URL safe, never for a value.
URLSearchParams — builds the query string for you and applies form encoding, which means it writes a space as + and a literal plus as %2B.
The mistake that produces the email bug:
// Wrong: & inside the value ends the parameter.
const url = `/search?q=${query}`;
// Right for one value:
const url = `/search?q=${encodeURIComponent(query)}`;
// Right for several, and handles + correctly:
const url = `/search?${new URLSearchParams({ q: query, page: 2 })}`;
Prefer URLSearchParams when building queries. It is harder to get wrong, and because it uses form encoding consistently on both sides, the plus problem does not arise.
Three rules that prevent the whole class of bug
Encode values, not URLs. Encode each parameter as you insert it. Encoding an assembled URL is already too late — by then you cannot tell a structural & from one that was inside a value.
Never encode twice. %20 encoded again becomes %2520, because the % itself gets encoded. Double encoding shows up as visible %25 in a URL bar and means some layer encoded something that was already safe. Encode at exactly one boundary and let it travel.
Decide the plus question once, at the edge. Pick form encoding for query strings — that is what browsers do — and make sure every producer and consumer in the chain agrees. Mixed conventions in one system is what turns an ambiguity into an incident.
Where it also matters
Percent-encoding shows up in more places than query strings, and the reserved set differs slightly in each:
- Path segments. A
/inside a filename must be%2For it creates a directory level that does not exist. - Fragments. Everything after
#never reaches the server, so encoding errors there are client-side only — and correspondingly harder to notice in server logs. - Data URLs. The payload after the comma is percent-encoded unless it is Base64. This is often why a hand-written data URL fails: a
#inside the SVG markup terminated the URL and started a fragment. Encoding it as%23fixes it, and Base64-encoding the whole payload avoids the question entirely.
Two standards, one syntax, one character of disagreement. It is a small piece of history that will keep producing bugs for as long as HTML forms exist — which is to say, indefinitely.