🍱 Lunchbox Hands

url

URL Encoding Explained: %20 vs +, and Which JavaScript Function to Use

Percent-encoding per RFC 3986, why + means space only in form data, exactly which characters encodeURI and encodeURIComponent leave alone, and the double-encoding bug that produces %2520.

A space in a URL becomes %20. Except when it becomes +. Except when the + was a real plus sign and decoding it as a space just corrupted someone’s phone number. All of this confusion has one root: URL encoding is not one scheme — it’s two overlapping schemes (RFC 3986 percent-encoding and HTML form encoding), and every bug comes from applying the rules of one in the territory of the other.

Once you know which scheme owns which part of the URL, the rest is mechanical.

Percent-encoding in one paragraph

A URL can only safely carry a small set of characters. RFC 3986 splits them into two groups:

  • UnreservedA–Z a–z 0–9 - . _ ~. These never need encoding, anywhere.
  • Reserved: / ? # [ ] @ (gen-delims) and ! $ & ' ( ) * + , ; = (sub-delims). These are the structure of a URL. They’re fine when used as structure, and must be encoded when they appear as data.

Everything else — spaces, quotes, non-ASCII — must be percent-encoded: take the character’s UTF-8 bytes and write each byte as % plus two hex digits. é is U+00E9, which is two bytes in UTF-8, so:

é  →  0xC3 0xA9  →  %C3%A9

That “as data” clause is why encoding is per-component, not per-URL. In /files/report.pdf, the slashes are path structure. In /files/Q3%2F2026.pdf, the %2F is a slash inside a filename — data. Same character, opposite treatment, and no function can tell the difference after you’ve already glued the string together. You encode each piece before assembly, or you’ve lost the information forever:

/search?q=cats&dogs      ← two parameters: q=cats, and a valueless "dogs"
/search?q=cats%26dogs    ← one parameter: q = "cats&dogs"

%20 vs +: two specs, one character

Here’s the precise version of the story. RFC 3986 has exactly one way to encode a space: %20. The + convention comes from somewhere else entirely — application/x-www-form-urlencoded, the serialization HTML forms use, which predates modern URL standards and encodes U+0020 as +.

Because GET forms put their form-encoded payload into the query string, +-for-space leaked into query strings specifically, and servers learned to decode it there. It never applied anywhere else.

ContextSpace encodes as+ means
Path (/my%20file.pdf)%20 onlyA literal plus. Never a space
Query string%20 always safe; + conventionalUsually a space — but server-dependent
Fragment (#section%202)%20A literal plus
application/x-www-form-urlencoded body+ (per the HTML spec)A space, always
Anything you decodeURIComponent()%20A literal plus — JS does not decode +

Two rules fall out of this table:

  1. When producing URLs, emit %20. It is unambiguous in every context. + is only obligatory when you are explicitly writing form-urlencoded data.
  2. When consuming, know your parser. Most server frameworks (PHP, Java servlets, Rails, Express’s query parser) decode + as a space in query strings because they treat the query as form data. JavaScript’s decodeURIComponent follows RFC 3986 and leaves + alone. This mismatch is exactly how "+15551234567" arrives as " 15551234567" — a real plus sign in a query value must be sent as %2B.

The JavaScript functions, precisely

JavaScript ships three encoders, and their entire difference is which characters they leave alone:

FunctionLeaves unencodedVerdict
escape()A–Z a–z 0–9 @ * _ + - . /Deprecated. Uses non-standard %uXXXX for anything above U+00FF (escape('ć')'%u0107'), which no URL parser understands. Never use it
encodeURI()Unreserved chars plus all URL structure: ; / ? : @ & = + $ , # and ! ~ * ' ( )For cleaning up a URL that is already assembled correctly. Cannot protect data, by design
encodeURIComponent()A–Z a–z 0–9 - _ . ! ~ * ' ( ) onlyThe default. Encodes / ? & = # + and friends, so data stays data

The classic bug is reaching for encodeURI because the variable is named url-something:

const q = 'Ben & Jerry';

encodeURI(`/search?q=${q}`);
// "/search?q=Ben%20&%20Jerry"  ← & untouched; the server sees q="Ben " plus junk

`/search?q=${encodeURIComponent(q)}`;
// "/search?q=Ben%20%26%20Jerry"  ← correct: one parameter, & is %26

encodeURI cannot fix this — leaving &, =, and ? intact is its whole job. It exists to percent-encode spaces and non-ASCII in a URL whose structure is already final, nothing more.

The honest footnote: even encodeURIComponent isn’t strictly RFC 3986. It leaves ! * ' ( ) unencoded, and those are reserved sub-delims. It’s almost never a problem, but if a picky API rejects them (OAuth 1.0 signatures famously did), MDN’s own recommendation is a follow-up replace of those five characters.

URLSearchParams and the URL API: stop concatenating

The modern answer to “which function do I call” is often neither — let the platform assemble the URL:

const url = new URL('https://example.com/search');
url.searchParams.set('q', 'cats&dogs');
url.searchParams.set('page', '2');
url.toString();
// "https://example.com/search?q=cats%26dogs&page=2"

Every value is encoded exactly once, in the right context, and you never touch a ? or & yourself.

One behavior to know: URLSearchParams speaks form-urlencoded, not RFC 3986. Its serializer percent-encodes everything except alphanumerics and * - . _, and it emits spaces as +:

new URLSearchParams({ q: 'two words' }).toString();  // "q=two+words"
new URL('https://x.com/?a=b c').href;                // "https://x.com/?a=b%20c"

Same URL machinery, two serializations — the URL object’s own .href renders the query with %20, while searchParams.toString() renders +. Both are valid in a query string; just don’t feed a URLSearchParams string through decodeURIComponent and expect the + to become a space, because it won’t. The symmetric upside: URLSearchParams parsing decodes + as a space for you, which plain decodeURIComponent never will.

Double-encoding: how %2520 happens

%2520 is the tombstone of a value that was encoded twice. The first pass turns a space into %20; the second pass doesn’t see a space, it sees a literal %, 2, 0 — and % itself must be encoded, as %25:

"my file"  →  "my%20file"  →  "my%2520file"

Decoded once, that renders as the literal text my%20file. You’ll see it when a framework auto-encodes values you already encoded, or when a URL is passed through two systems that each “helpfully” encode.

The mirror-image bug is decoding twice: %2541 decodes to %41, which a second decode turns into A — a well-known trick for smuggling ../ past path-traversal filters as %252E%252E%252F.

The rule that prevents both: encode exactly once, at the last boundary before the string becomes a URL, and decode exactly once, immediately after parsing. In between, keep values raw. If your code ever contains decodeURIComponent(decodeURIComponent(x)), you haven’t found a fix — you’ve found the place where an extra encode happens upstream.

Non-ASCII domains are a different mechanism entirely

Percent-encoding stops at the hostname. Internationalized domain names use Punycode: café.example becomes xn--caf-dma.example, an ASCII transformation defined by the IDNA specs, and the browser does it before DNS ever sees the name. If you percent-encode a hostname, you’ve made an invalid URL, not an international one — %C3%A9 is meaningful in a path or query, never in a domain.

Debugging a mangled URL

When a URL arrives broken — a redirect chain, an analytics export, an email-click-tracker sandwich — don’t squint at the raw string. Paste it into our URL parser and it breaks out protocol, host, path, query, and fragment, with every query parameter decoded in a table. Double-encoding is instantly visible: a value that still contains %20 after the table has decoded it was encoded twice.

For one-off conversions, the URL encoder/decoder runs encodeURIComponent/decodeURIComponent in your browser — so per the table above, it treats + as a literal plus, which makes it a handy way to check what strict RFC-style decoding does to a string. And when you’re building query strings from structured data, the JSON to query string converter does the assembly-plus-encoding step for you in both directions.

Two situations where the best encoding is none at all:

  • Slugs. If you control the path, don’t put “My Great Post!” in it and encode the damage — generate my-great-post with the text-to-slug converter and the question disappears.
  • Campaign parameters. UTM values are a top source of half-encoded URLs, because they’re pasted between spreadsheets, link shorteners, and ad platforms that each touch the string. We covered the conventions in UTM parameters explained.

The short version

You are…Do this
Inserting a value into a query or pathencodeURIComponent(value) — never encodeURI
Building a whole URL from partsnew URL() + searchParams and skip manual encoding
Writing a space%20 everywhere; + only in form-urlencoded data
Sending a literal + in a query%2B, or servers will read it as a space
Seeing %2520Something encoded twice — fix the extra encode, don’t decode twice
Tempted by escape()Don’t. It’s deprecated and mangles non-Latin-1 text