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 except A-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 the application/x-www-form-urlencoded MIME 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

  1. Unreserved (A-Z a-z 0-9 - . _ ~): leave alone, always.
  2. Reserved with structural meaning: encode when it is data, leave when it is syntax.
  3. Everything else: encode, operating on UTF-8 bytes.
  4. Query parameter values: use encodeURIComponent, which gives you %20 for spaces.
  5. 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.