Every URL is built from a small alphabet of safe ASCII characters; anything else — a space, an ampersand meant as data, a non-ASCII letter — must be percent-encoded. This guide covers which characters are reserved, which never need encoding, how to encode in JavaScript, and the bugs that follow when encoding goes wrong.
Why URLs need encoding
RFC 3986, the standard that defines URIs, allows only a limited set of characters to appear in a URL. Any other byte is written as a percent sign followed by two hexadecimal digits: a space becomes %20, a literal percent sign becomes %25. Non-ASCII text is first converted to UTF-8 bytes, and each byte is encoded separately — é (U+00E9) is the two bytes C3 A9 in UTF-8, so it appears as %C3%A9, and the euro sign becomes %E2%82%AC.
Encoding matters twice over: unsafe bytes have no defined meaning on the wire, and reserved characters carry structural meaning — so when one appears as data, like the & in a search for "Rock & Roll", it must be encoded or it breaks parsing.
Reserved characters: the full percent-encoding table
RFC 3986 reserves 18 characters. The first seven are gen-delims, the URL's structural punctuation; the remaining eleven are sub-delims, kept in reserve for schemes and formats that need extra separators.
| Char | Encoded | What it does in a URL |
|---|---|---|
: | %3A | Separates the scheme from the rest (https:), and the host from the port (:8080) |
/ | %2F | Separates path segments |
? | %3F | Starts the query string |
# | %23 | Starts the fragment; everything after it stays in the browser and never reaches the server |
[ | %5B | Opens an IPv6 host literal, as in http://[::1]/ |
] | %5D | Closes an IPv6 host literal |
@ | %40 | Separates userinfo from the host (user@example.com) |
! | %21 | Sub-delimiter; some frameworks use it inside paths |
$ | %24 | Sub-delimiter |
& | %26 | Separates query parameters (?a=1&b=2) |
' | %27 | Sub-delimiter |
( | %28 | Sub-delimiter |
) | %29 | Sub-delimiter |
* | %2A | Sub-delimiter |
+ | %2B | Sub-delimiter; means a space in a form-encoded query (see below) |
, | %2C | Sub-delimiter; often used for comma-separated lists in paths |
; | %3B | Sub-delimiter; historically a parameter separator |
= | %3D | Separates a parameter name from its value |
Reserved characters may appear unencoded only when they are doing their structural job. A ? that starts your query string stays bare; a ? inside a search term must become %3F.
Unreserved characters: never encode these
The 66 unreserved characters — A–Z, a–z, 0–9, hyphen -, underscore _, period . and tilde ~ — are safe in every position of every URL and never need encoding. RFC 3986 treats an encoded unreserved character as identical to the plain one (%7E equals ~), but encoders should emit the plain form. One quirk: JavaScript's encodeURIComponent also leaves ! ' ( ) * unencoded even though they are reserved — usually harmless, though a strict server may complain.
encodeURIComponent vs encodeURI in JavaScript
JavaScript offers two encoders, and choosing wrong is the most common URL bug.
encodeURI | encodeURIComponent | |
|---|---|---|
| Encodes spaces and non-ASCII | Yes | Yes |
Keeps URL structure (: / ? & = #) intact | Yes | No — encodes all of them |
| Right job | A complete URL you already trust | A single query name or value |
Examples, exactly as your browser computes them:
encodeURI("https://example.com/a b?q=café") → https://example.com/a%20b?q=caf%C3%A9 — the structure survives, only the space and the é are encoded.
encodeURIComponent("café au lait") → caf%C3%A9%20au%20lait
encodeURIComponent("price=$5&tax") → price%3D%245%26tax — the = and & are neutralized, so the value can sit safely inside a query string.
The rule of thumb: when building a query string, run encodeURIComponent on each name and each value, then join them with = and &. Never run it on a whole URL — it would encode the : and / too.
The space problem: %20 vs +
Both %20 and + can mean a space, but they come from different standards. Percent-encoding (RFC 3986) only knows %20. The plus sign comes from HTML form encoding, application/x-www-form-urlencoded, which browsers and JavaScript's URLSearchParams still use: new URLSearchParams({q:"a b"}).toString() returns q=a+b. Nearly every server decodes a + as a space in the query string, but inside a path a plus is a literal plus — /files/a+b and /files/a%20b are two different files. encodeURIComponent always emits %20, which is correct in every position.
Common encoding bugs
Double-encoding
Encoding an already-encoded string encodes the percent signs themselves: encodeURIComponent("100%") correctly gives 100%25, but encode that result again and you get 100%2525, which the server reads as the literal text "100%25". This happens when a framework encodes a redirect URL you already encoded, or when a whole URL containing %C3%A9 is fed to an encoder — the é comes out as caf%25C3%25A9. Encode exactly once, at the boundary where the value enters the URL.
Decoding before splitting
Parse a query string in this order: split on &, split each pair on =, then decode each name and value. If you decode the whole string first, an encoded %26 inside a value becomes a real & and silently splits one value into two parameters.
Non-ASCII and UTF-8 bytes
decodeURIComponent assumes UTF-8. That is correct for anything modern — é is %C3%A9, € is %E2%82%AC — but legacy systems sometimes emit single-byte Latin-1 encodings like %E9 for é. Feeding that to decodeURIComponent throws "URI malformed" because E9 alone is not a valid UTF-8 sequence. When you must accept such input, wrap the decode in a try/catch or use a byte-aware decoder.
Try it live: paste any text into the URL Encoder / Decoder and watch the encoded bytes appear as you type — everything runs in your browser, nothing is uploaded.