Percent-encoding, and why your URL breaks
encodeURI is not encodeURIComponent, + only means space in half a URL, and %2F becomes %252F the moment two layers both try to help. Every claim here printed from a Node console.
The bug report says the search box is broken. Not always — only for one customer, only sometimes. You get the failing request out of the logs and the query parameter reads sim, when the customer definitely typed sim&plan=5G. Nothing threw. No 400. The server received a perfectly valid request that simply was not the one anybody sent.
That whole class of bug comes down to percent-encoding, and specifically to the fact that JavaScript hands you three functions for it that do three different things. Everything below was run in a Node 22 console before it was written down, and the output is pasted verbatim — run it yourself as you read, because the surprising bits are much more convincing on your own machine than on mine.
Three functions, and one you should never call
Take one string with the awkward characters in it and put it through all three. The whole problem is visible in three lines of output.
> const s = "https://api.jsontotable.io/v1/sim?q=a b¬e=100% sure"
> encodeURI(s)
'https://api.jsontotable.io/v1/sim?q=a%20b¬e=100%25%20sure'
> encodeURIComponent(s)
'https%3A%2F%2Fapi.jsontotable.io%2Fv1%2Fsim%3Fq%3Da%20b%26note%3D100%25%20sure'
> escape(s)
'https%3A//api.jsontotable.io/v1/sim%3Fq%3Da%20b%26note%3D100%25%20sure'Look at what happened to the colon and the slashes. encodeURI left them alone, because it assumes you handed it a whole URL and those characters are doing structural work. encodeURIComponent flattened them, because it assumes the string is one value that has to survive being dropped into a URL without changing its shape. Both are correct. They are answering different questions.
escape is the odd one out and it is the one to delete on sight. It encoded the colon but not the slashes, which matches no specification at all, and its behaviour on anything outside Latin-1 is worse than useless.
> escape("Sigrid Håkonsdóttir")
'Sigrid%20H%E5konsd%F3ttir'
> encodeURIComponent("Sigrid Håkonsdóttir")
'Sigrid%20H%C3%A5konsd%C3%B3ttir'
> escape("€")
'%u20AC'
> decodeURIComponent(escape("€"))
Uncaught URIError: URI malformed%u20AC is not percent-encoding. It is a Netscape-era invention that no server, no proxy and no other language understands, and as the last line shows, JavaScript's own decoder rejects it. The %E5 in the first line is the Latin-1 byte for å rather than its UTF-8 pair %C3%A5, so anything expecting UTF-8 — which is everything written this century — gets a mojibake name. MDN marks escape as deprecated and it survives only in the ECMAScript annex for legacy browser compatibility. There is no situation where it is the right call.
Which brings us back to the broken search box. Here is the failing request and the working one side by side.
> const q = "sim&plan=5G"
> new URL(encodeURI("https://x.io/find?q=" + q)).searchParams.get("q")
'sim'
> new URL("https://x.io/find?q=" + encodeURIComponent(q)).searchParams.get("q")
'sim&plan=5G'encodeURI deliberately preserved the & the user typed, because in a URL an ampersand separates parameters and encodeURI has no way to know this one was meant as data. The server split there, took everything before it, and the rest of the customer's search became a parameter called plan that nobody reads. Nothing in that chain is a bug. It is the wrong function.
Why + is a space in a query string and not in a path
This is the single most common percent-encoding bug I have watched people hit, and the reason is that a URL is governed by two specifications at once, not one.
RFC 3986 defines URIs, and under it a space is %20 everywhere and + is just a plus sign. But the query string of a form submission is serialised by a separate rule — application/x-www-form-urlencoded, defined in the HTML standard — and that one, inherited from HTML forms in the mid-nineties, writes a space as +. Both are live. Both apply to different halves of the same string.
> new URLSearchParams("q=a+b").get("q")
'a b'
> decodeURIComponent("a+b")
'a+b'
> const u = new URL("https://api.jsontotable.io/reports/Q1+Q2/summary?range=Q1+Q2")
> u.pathname
'/reports/Q1+Q2/summary'
> u.searchParams.get("range")
'Q1 Q2'Read those last two lines again. One URL, the identical three characters Q1+Q2 appearing twice, and the parser gives you a plus in the path and a space in the query. It is not being inconsistent — it is applying the form-urlencoded rule to the query component and RFC 3986 to the path component, which is exactly what the WHATWG URL Standard specifies.
The expensive version of this is decoding a query value with the wrong decoder. URLSearchParams knows about the plus rule; decodeURIComponent does not, and it will not warn you.
> const form = new URLSearchParams({ band: "5G+ / 100% coverage" }).toString()
> form
'band=5G%2B+%2F+100%25+coverage'
> new URLSearchParams(form).get("band")
'5G+ / 100% coverage'
> decodeURIComponent(form.split("=")[1])
'5G++/+100%+coverage'Every space came back as a literal plus, and the genuine plus in 5G+ — correctly written as %2B on the way out — decoded to a plus that is now indistinguishable from the fake ones. The string survived a round trip and came back wrong. Use the parser that matches the serialiser, always.
Raw JSON in a query parameter
Filter objects in query strings are everywhere, and pasting the JSON in unencoded works right up until the JSON contains an interesting character. Here is a filter that does.
{
"apn": "internet",
"roaming": true,
"note": "a=b&c",
"plan": "5G+"
}> const filter = JSON.stringify(require("./filter.json"))
> const u = new URL("https://api.jsontotable.io/sims?filter=" + filter)
> for (const [k, v] of u.searchParams) console.log(k, "=", v)
filter = {"apn":"internet","roaming":true,"note":"a=b
c","plan":"5G "} =
> JSON.parse(u.searchParams.get("filter"))
Uncaught SyntaxError: Unterminated string in JSON at position 44 (line 1 column 45)Three separate failures in one line, and it is worth naming each because they fail differently. The & inside "a=b&c" ended the parameter, so the filter got truncated mid-string. The = after it made the rest of the JSON look like a second parameter with an empty value — that is the trailing = on the second output line. And the + in "5G+" silently became a space, which is the one that will not throw anything anywhere: you get a filter for plan 5G and zero results.
One function call fixes all three, because encodeURIComponent exists precisely for the case where a value contains URL punctuation.
> const good = new URL("https://api.jsontotable.io/sims?filter=" + encodeURIComponent(filter))
> good.href
'https://api.jsontotable.io/sims?filter=%7B%22apn%22%3A%22internet%22%2C%22roaming%22%3Atrue%2C%22note%22%3A%22a%3Db%26c%22%2C%22plan%22%3A%225G%2B%22%7D'
> good.searchParams.get("filter")
'{"apn":"internet","roaming":true,"note":"a=b&c","plan":"5G+"}'
> JSON.parse(good.searchParams.get("filter")).plan
'5G+'Unreadable, and that is fine — nobody reads it, and searchParams.get hands the document back byte for byte. If the encoded blob is long enough to trip a URL length limit somewhere in your stack, that is the point at which Base64 in the parameter or a POST body starts to look sensible, not before.
Double encoding, and reading the symptom
Two layers both being helpful is how %2F turns into %252F. It happens because a percent sign is itself a character that needs encoding, so encoding an already-encoded string encodes its escape sequences.
> encodeURIComponent("eu-west/sim-99213")
'eu-west%2Fsim-99213'
> encodeURIComponent(encodeURIComponent("eu-west/sim-99213"))
'eu-west%252Fsim-99213'
> decodeURIComponent("eu-west%252Fsim-99213")
'eu-west%2Fsim-99213'
> decodeURIComponent("%252F")
'%2F'The tell is that one decode gives you a string that still visibly contains percent escapes. If a value arrives at your handler reading eu-west%2Fsim-99213 after you decoded it, it was encoded twice on the way in — you are not looking at a weird ID, you are looking at a layer count.
Which layer, though? The encoded character tells you, because different layers reach for different serialisers. This is the table I check against, in the order the cases actually turn up.
| What you see in the log | What it decodes to once | Almost always |
|---|---|---|
%2520 | %20 | A value that was already URL-encoded got passed through encodeURI or encodeURIComponent a second time — usually a helper that encodes, wrapped by an HTTP client that also encodes |
%252F | %2F | An ID containing a slash was encoded for a path segment, then the whole path was encoded again by a router or a proxy |
%2B where you expected a space | + | Not double encoding. A form-urlencoded value read with decodeURIComponent instead of a query-string parser |
%25 at the end of a value | % | Correct, and the common false alarm — a genuine percent sign in the data, such as 100% |
That last row matters more than it looks. A real percent sign in user data is what breaks the naive decoder: decodeURIComponent("100% sure") throws URIError: URI malformed, because % s is not a valid escape. So if you are trying to prove a value was double-encoded, do not test by decoding until it stops changing — decode once, look at what you have, and decide. Running a decode step in isolation, one layer at a time, is faster than reasoning about it.
Reserved, unreserved, and the slash everyone argues about
RFC 3986 section 2.3 defines the unreserved set — letters, digits, and exactly four punctuation marks: hyphen, period, underscore and tilde. Those never need encoding and must never be decoded differently from their literal selves. Everything else is either reserved (it means something structurally) or has to be escaped.
Here are the two functions measured against that set, printing every ASCII character each one leaves untouched.
// every printable ASCII character each function leaves alone
const all = [];
for (let i = 32; i < 127; i++) all.push(String.fromCharCode(i));
all.filter(c => encodeURIComponent(c) === c).join("")
all.filter(c => encodeURI(c) === c).join("")
encodeURIComponent leaves untouched: !'()*-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~
encodeURI leaves untouched : !#$&'()*+,-./0123456789:;=?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~
RFC 3986 unreserved : -.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~
extra vs RFC unreserved (component): ! ' ( ) *encodeURIComponent is slightly looser than the RFC: it leaves !, ', (, ) and * alone. Those were "mark" characters in the older RFC 2396 and the function has never been updated, which MDN's page on encodeURIComponent documents along with the one-line fix if you need strict RFC 3986 output. In practice it bites when a parenthesis in a value confuses a Markdown link or a log parser downstream, not the URL itself.
The genuinely contested one is an encoded slash inside a path segment. A slash separates path segments, so an ID that contains a slash has to be written %2F — and then servers disagree about what that means.
> new URL("https://api.jsontotable.io/sims/eu-west%2Fsim-99213/usage").pathname
'/sims/eu-west%2Fsim-99213/usage'
> new URL("https://api.jsontotable.io/sims/eu-west/sim-99213/usage").pathname.split("/")
[ '', 'sims', 'eu-west', 'sim-99213', 'usage' ]
> new URL("https://x.io/a/b/../c").pathname
'/a/c'
> new URL("https://x.io/a/b/%2E%2E/c").pathname
'/a/c'Note the asymmetry in those four lines. The URL parser keeps %2F encoded, so the segment stays one segment — but it decodes %2E%2E and then removes the dot segment anyway, so encoding a dot buys you nothing. Percent-encoding protects a slash from the parser and does not protect a period from it.
That is the client half. The server half is where it stops being predictable: Apache rejects encoded slashes in paths with a 404 unless AllowEncodedSlashes is switched on, Tomcat blocks them by default for path-traversal reasons, and several proxies decode the path before routing, at which point your one segment quietly becomes two and hits a different handler. None of this is any implementation being wrong — RFC 3986 leaves it to the server whether an encoded reserved character is equivalent to its decoded self, and different products made different calls.
The rules I actually follow
None of this needs memorising in full. Five habits cover essentially every case, in rough order of how often each one has saved me:
- Build URLs with
URLandURLSearchParams, not string concatenation.url.searchParams.set(k, v)encodes the value correctly and encodes it exactly once, which removes both the wrong-function bug and the double-encoding bug in one move. - If you must concatenate, it is
encodeURIComponenton the value. Not on the URL. The moment you find yourself encoding something that contains a?you meant to keep, you have picked the wrong one. - Decode with the same rule you encoded with. Query values came out of a form-urlencoded serialiser, so they go back through a query-string parser. Path segments go through
decodeURIComponent. - Encode once, at the boundary. Pick the layer that owns URL construction and let every layer underneath it pass raw values. Most
%252Fsightings are two layers that both thought they were the boundary. - Delete
escapewherever you find it. It has produced%u20ACfor the euro sign since 1997 and nothing else in the world speaks that.
And when a request is failing and you cannot see why, take the URL apart before you take the code apart. Pulling a live URL through a URL parser shows you the path, query and fragment as the browser and the server split them, and turning the query into an object with the JSON and query-string converters shows you the values as the handler will receive them. Nine times in ten the answer is visible right there, and it is one of the four things above.