URL encoding is one of those things you use constantly without thinking about it — until a query parameter breaks and you spend twenty minutes staring at a %20 that should have been a +. Or the other way round. It's worth understanding what's really happening so you can fix these bugs fast instead of guessing.

What URL encoding actually is

URLs are only allowed to contain a limited set of ASCII characters. The spec (originally RFC 1738, now RFC 3986) reserves certain characters for structural meaning — things like ?, &, =, /, #. If you want to put one of those characters inside a value rather than use it as a separator, you have to escape it.

Encoding replaces a character with a percent sign followed by its hexadecimal byte value. A space becomes %20. An ampersand becomes %26. Non-ASCII characters get encoded per byte after being converted to UTF-8, so an em dash (—) becomes %E2%80%94 — three bytes, three percent-escapes.

That's the whole idea. It's not encryption, it's not obfuscation, it's just making arbitrary text safe to travel inside a URL.

When you actually need it

The most common case is building query strings from user input or dynamic data:

  • Search terms that contain spaces, ampersands, or plus signs
  • Redirect URLs passed as a parameter (a URL inside a URL)
  • OAuth flows, where the redirect_uri and state params must be encoded exactly
  • Building API request paths that include IDs with special characters
  • Encoding form data for application/x-www-form-urlencoded request bodies

The redirect-URL-inside-a-URL case is where most people get burned. If your callback contains its own query string, those & and = characters will be read as part of the outer URL unless you encode them.

The gotchas that bite in production

Space is not always %20

In the path portion of a URL, a space is %20. In a query string encoded as form data, a space can legitimately be +. The application/x-www-form-urlencoded format defines + as space, which is a historical quirk. If you decode a form-encoded string with a strict path decoder, your pluses survive as literal pluses and your data is wrong. Know which format you're dealing with.

Double encoding

Encoding an already-encoded string turns %20 into %2520 — because the % itself gets escaped to %25. This happens a lot when a value passes through two layers that both "helpfully" encode it. If you see %25 where you expected a special character, you've got double encoding somewhere in the chain.

encodeURI vs encodeURIComponent

In JavaScript these are not interchangeable. encodeURI is meant for encoding a whole URL and leaves structural characters like ?, &, and / alone. encodeURIComponent encodes those too, which is what you want for a single query parameter value.

encodeURI("https://x.com/a b?q=1&r=2")
// "https://x.com/a%20b?q=1&r=2"

encodeURIComponent("https://x.com/a b?q=1&r=2")
// "https%3A%2F%2Fx.com%2Fa%20b%3Fq%3D1%26r%3D2"

Use encodeURIComponent for values, encodeURI when you have a full URL you just want to make transport-safe. Getting this wrong is the number one cause of broken redirect parameters.

Reserved vs unreserved characters

Characters like -, _, ., and ~ are "unreserved" and never need encoding. But not every library agrees on the edges. Some encoders escape !, *, (, and ); JavaScript's encodeURIComponent does not. If two systems disagree on a signed URL or an OAuth signature, this mismatch will produce a signature failure that looks like nothing is wrong.

A short worked example

Say you're building a link that redirects users back to a page after login, and that target page has its own query parameters:

const target = "https://app.example.com/dashboard?tab=billing&view=annual";

const loginUrl =
  "https://auth.example.com/login?redirect=" +
  encodeURIComponent(target);

console.log(loginUrl);
// https://auth.example.com/login?redirect=https%3A%2F%2Fapp.example.com%2Fdashboard%3Ftab%3Dbilling%26view%3Dannual

Notice the inner & became %26 and the ? became %3F. If you hadn't encoded them, the auth server would read view=annual as its own top-level parameter and the redirect would arrive at your app missing the billing tab.

On the receiving end you reverse it:

const params = new URLSearchParams(window.location.search);
const redirect = params.get("redirect");
// URLSearchParams already decodes, so redirect is the original URL

console.log(redirect);
// https://app.example.com/dashboard?tab=billing&view=annual

One thing worth noting: URLSearchParams handles decoding for you, so you don't call decodeURIComponent again on the result. Doing so is a fast way to introduce a decode-of-already-decoded bug.

Debugging by hand

When something looks off, the quickest fix is to take the exact string that's failing and decode it to see what it really contains. Seeing the raw characters usually makes the problem obvious — a stray %25, a plus that should be a space, or a value that was never encoded at all.

I keep a browser tab open with the URL Encoder / Decoder for exactly this. Paste in a suspicious parameter, decode it, and you can immediately tell whether you're looking at single encoding, double encoding, or plain garbage. It beats writing a throwaway console.log every time.

Rules of thumb

  • Encode individual values with encodeURIComponent, whole URLs with encodeURI.
  • Encode once. If you're building the string in layers, be sure only one layer does the escaping.
  • Decide upfront whether you're in path space (%20) or form space (+) and keep the encoder and decoder on the same page.
  • For signed URLs and OAuth, verify both ends use the same character set — mismatched escaping of * or ~ will silently break signatures.
  • Prefer URLSearchParams and the URL object over manual string concatenation when you can. They handle most of this correctly.

URL encoding isn't complicated once you internalise that it's just "make text safe for a URL, one byte at a time." The bugs almost always come from encoding too much, too little, or in the wrong place — not from the concept itself.

Photo by Mohammad Rahmani on Unsplash