Every character in a URL has a job, and only a small subset of characters are allowed to appear verbatim. Everything else — spaces, non-ASCII text, and anything with structural meaning — has to be encoded as a % followed by two hex digits. Get this wrong and your ?name=José&q=a b becomes a malformed request, a broken link, or a double-encoding bug that surfaces only in production.
Percent-encoding: the %HEXHEX triplet
Defined in RFC 3986 (Uniform Resource Identifier: Generic Syntax), percent-encoding is the mechanism for representing characters that cannot safely appear in a URI. A byte that needs encoding is written as a percent sign followed by two hexadecimal digits: a space becomes %20, a literal percent becomes %25, and the byte 0x2F (a forward slash, when it needs escaping) becomes %2F.
The encoding works on bytes, not characters. A non-ASCII character is first converted to UTF-8, and each resulting byte is then percent-encoded individually. The letter é (U+00E9) becomes the two bytes C3 A9 in UTF-8, so it appears in a URL as %C3%A9. This is why the same character can occupy six characters of URL space.
The unreserved character set
RFC 3986 Section 2.3 defines a tiny set of characters that are unreserved and should never be percent-encoded:
unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
That is A-Z, a-z, 0-9, and exactly four punctuation marks: hyphen -, period ., underscore _, and tilde ~. These are safe in any component of a URL.
Everything else falls into two groups:
- Reserved characters —
: / ? # [ ] @ ! $ & ' ( ) * + , ; =— have syntactic meaning. Whether they need encoding depends on the component. A?in a path must be encoded (%3F) because it would otherwise start the query string; a?as the query-string delimiter is left literal. - Everything else — spaces, control characters,
<,>,",{,}, non-ASCII — must always be encoded.
A subtle gotcha: the tilde ~ was not unreserved in the older RFC 2396. Some legacy systems still encode it as %7E, which is harmless but redundant under RFC 3986.
encodeURIComponent vs encodeURI
JavaScript exposes two encoders, and using the wrong one is a common bug:
encodeURI(s)preserves characters that are structural in a complete URL:; , / ? : @ & = + $ #. Use it when you have a full URL and want to clean up stray unsafe characters without breaking its syntax.encodeURIComponent(s)encodes everything exceptA-Z a-z 0-9 - . _ ~. Use it for a single query parameter value — the structurally meaningful characters inside that value (&,=,+) must be escaped so they are not misread as delimiters.
const url = "https://example.com/search?q=" + encodeURIComponent("a & b = c");
// "https://example.com/search?q=a%20%26%20b%20%3D%20c"
If you had used encodeURI here, the & and = would have survived, and the server would have parsed the value as a with a second parameter b = c — a classic injection-shaped bug.
You can test both behaviours and inspect the byte-level output in the URL encoder / decoder.
The + vs %20 trap
Here is the confusion that bites everyone once. There are two ways to encode a space:
%20— the RFC 3986 percent-encoded form, valid anywhere in a URL.+— a legacy convention from theapplication/x-www-form-urlencodedMIME type (HTML form submissions). In that format only, a space is encoded as+, and a literal+is encoded as%2B.
The mismatch shows up when you mix layers. If you build a query string with encodeURIComponent, spaces become %20 (correct, RFC-compliant). If you parse it on the server with a routine designed for form data, it will leave %20 alone and only decode + back to space — so your value arrives intact. But if you encode a value with form-style + for spaces and then parse it with an RFC-3986-aware decoder, the + survives and you get a literal plus in your string.
The safe rule: use %20 in paths, in fragments, and anywhere a query string is constructed by hand or by encodeURIComponent. Reserve +-for-space for actual HTML form payloads, and be aware of which decoder the receiving end is using.
Double-encoding: the %2520 problem
When a value that is already percent-encoded gets encoded a second time, the % itself is encoded to %25. A space that was %20 becomes %2520. When the server decodes once, it gets back %20 (a literal percent, two, zero) rather than a space.
This happens when one layer of your stack encodes and a second layer does too — a reverse proxy that rewrites query strings, a framework that auto-encodes path segments, or a developer who manually encodes a value that the HTTP client already encoded. The fix is to encode exactly once, at the boundary where the value enters the URL. If you ever see %2520 or %252F in your logs, you have found a double-encoding bug.
Non-ASCII and UTF-8
Because percent-encoding operates on bytes, any non-ASCII character is first UTF-8 encoded and then each byte is escaped. Modern browsers and servers consistently use UTF-8, but the URI spec itself historically did not mandate an encoding. The WHATWG URL Living Standard — the standard browsers actually implement — resolves this by specifying UTF-8 throughout the URL parser. In practice, treat UTF-8 as universal: a Japanese string like 東京 becomes %E6%9D%B1%E4%BA%AC (six bytes, six triplets).
A quick mental model
- Unreserved (
A-Z a-z 0-9 - . _ ~): leave alone, always. - Reserved with structural meaning: encode when it is data, leave when it is syntax.
- Everything else: encode, operating on UTF-8 bytes.
- Query parameter values: use
encodeURIComponent, which gives you%20for spaces. - Form payloads (
x-www-form-urlencoded): spaces are+, but only there.
Master these five rules and the vast majority of URL-handling bugs disappear. The rest is checking your boundaries — encode once, at the right place, and let every downstream layer treat the result as opaque.