URL encoding, explained — why your %20 keeps breaking

Type a space into a URL and the browser quietly turns it into %20. Paste a link with an emoji and it becomes a wall of percent signs. Encode something twice and the server hands back %2520 and you stare at it like it's a typo.

All of it is one small system — percent-encoding, the way URLs carry characters they weren't originally designed for. It's small enough to learn in six minutes, and once you do, a whole category of "why is this link broken" bugs disappears. Here it is.

In this post
  • Why URLs are ASCII-only, and what percent-encoding actually is
  • Reserved vs. unreserved characters — and why context decides everything
  • The %20 vs. + space confusion, settled once and for all
  • The %2520 double-encoding bug and how to avoid it
  • encodeURIComponent vs. encodeURI — which one you want

Why URLs need encoding at all

URLs were built to travel through any system that handles text — and that means they're restricted to a safe subset of ASCII. No spaces, no emoji, no accented characters, and a set of reserved characters that carry syntactic meaning: : / ? # & = and friends.

So when you need one of those characters as data — a space in a filename, an & inside a search query, a / in a parameter — you can't just put it there. It would be read as syntax. Percent-encoding is the escape hatch: replace the character with a % followed by two hex digits representing its byte value.

percent-encoding.txt
space    %20        ASCII 32 = 0x20
/        %2F        when it's DATA, not a path separator
=        %3D        when it's data, not a key/value separator
&        %26        when it's data, not a parameter separator
é        %C3%A9     non-ASCII → UTF-8 bytes, then each byte encoded
🙂       %F0%9F%99%82  an emoji is 4 UTF-8 bytes → 4 groups

# a whole string:
"café & co"    caf%C3%A9%20%26%20co

That's the whole mechanism. Non-ASCII characters get converted to UTF-8 bytes first, then each byte is percent-encoded — which is why a single emoji balloons into twelve characters.

Reserved vs. unreserved — context is everything

Characters split into two camps, and the difference is whether the URL grammar gives them a job:

the-two-camps.txt
UNRESERVED — never need encoding:
A-Z  a-z  0-9  -  _  .  ~

RESERVED — syntax characters; encode only when they're DATA:
:  /  ?  #  [  ]  @  !  $  &  '  (  )  *  +  ,  ;  =

# the same character, two contexts:
https://example.com/a/b          / is a path separator — leave it alone
https://example.com/?path=a%2Fb  / is DATA here — encode it

This is the idea that makes everything else click: a character only needs encoding when it would be misread as syntax. The / in a path is doing its job; the / inside a query parameter is an imposter. Same character, different context, different treatment.

The space problem — %20 vs. +

Spaces are the character everyone actually runs into, and they have two encodings, which is the source of most of the confusion:

two-spaces.txt
# in a PATH, a space is always %20
https://example.com/my%20file.txt

# in a QUERY STRING (form encoding), a space can be +
https://example.com/search?q=hello+world

# %20 works in BOTH places. + means "space" ONLY in query strings.
+ means space in exactly one place

The +-for-space convention comes from HTML form encoding (application/x-www-form-urlencoded) and applies only in query strings. In a path, + is a literal plus sign. Use + in a path and the server reads a plus; your "fix" just created a different filename. When in doubt, %20 is correct everywhere.

Encoding and decoding in JavaScript

JavaScript gives you two encode functions, and picking the wrong one is half of all encoding bugs:

encode.js
// encode a VALUE — what you want 95% of the time
encodeURIComponent("café & co")   // "caf%C3%A9%20%26%20co"

// encode a whole URL — preserves :/?# so it stays a valid URL
encodeURI("https://x.com/a b")    // "https://x.com/a%20b"

// decode
decodeURIComponent("caf%C3%A9")   // "café"

// building a query string the safe way:
const url = "https://api.example.com/search?q=" + encodeURIComponent(userInput);
The one-line rule

encodeURIComponent for values you're putting into a URL. encodeURI only when you're encoding a complete URL and need to keep its syntax intact. If you're asking "which one?" the answer is almost always encodeURIComponent.

The double-encoding bug — %2520

Here's the bug that produces the weirdest-looking output. The % character is itself a reserved character — so if you encode something that's already encoded, the % gets encoded to %25:

double-encoding.txt
# encode once — correct
" "    %20

# encode twice — the bug (% itself becomes %25)
%20    %2520

# the server decodes ONCE and gets the literal text "%20"
Encode values, once, at assembly time

Double-encoding happens when a value gets encoded, then the whole URL — or the value again — gets encoded a second time. The server decodes once and treats %20 as literal text. The fix is a discipline, not a function: encode each value exactly once, at the moment you build the URL, and never re-encode a URL that's already assembled.

The mirror-image mistake is encoding the whole URL — turning https:// into https%3A%2F%2F. That destroys the syntax that makes it a URL. Values get encoded; the URL's own structure does not.

The mistakes that actually happen

  • Encoding the whole URL instead of just the values — the separators get encoded and the URL stops being a URL.
  • Using + for a space in a path. It's a literal plus there. %20 is the only space encoding that works everywhere.
  • Double-encoding — the %2520 above. Encode once, at the point of assembly.
  • Putting raw user input into a URL. Anything a user typed must pass through encodeURIComponent first — an unencoded & or # silently truncates or corrupts the request.
  • Assuming decodeURIComponent never fails. It throws on malformed input (a lone %, a bad hex pair) — wrap it if the input isn't guaranteed.

The full grammar — every reserved character and the exact rules — is RFC 3986, and the MDN page for encodeURIComponent is the reference you'll actually keep open.

Wrap-up

URLs are ASCII, so anything else travels as % plus two hex digits. Reserved characters only need encoding when they're data, spaces are %20 everywhere (+ only in query strings), and the two bugs that actually happen are double-encoding and encoding the whole URL. Encode values once, with encodeURIComponent, and the percent signs stop being mysterious.

Encoding is one of those topics that pairs with everything else on this blog — the base64 vs. base64url post covers the other encoding that trips people up, and when a mangled URL finally does reach the server, the HTTP status lookup will tell you what it thinks of the result. And if your encoded request is correct but still getting blocked, that's usually a CORS conversation, not an encoding one.