Skip to main content
URLs

URL encoding: encodeURI vs encodeURIComponent, and the + vs %20 trap

Which JavaScript function to use for which part of a URL, why a space is sometimes + and sometimes %20, and how double-encoding quietly corrupts query strings.

Thien
By Thien
Updated October 3, 2026 · 5 min read
close-up of a laptop screen showing a search engine page in a browser

You build a search link, a user types Tom & Jerry, and your backend receives a query of just Tom . Or you encode a redirect URL twice and the login page 404s on %2520. Both bugs come from the same place: encoding the wrong thing with the wrong function.

Short answer: use encodeURIComponent() on each value you put into a URL (a query parameter, a path segment). Use encodeURI() only on a whole URL you're tidying up, and almost never. In a query string a space may be + or %20, but in a path it must be %20. Encode exactly once, as the last step before the URL is assembled.

What each function leaves alone

Both functions percent-encode UTF-8 bytes. They differ in which ASCII characters they treat as structure and leave untouched. I ran these in Node 22:

InputencodeURIComponentencodeURI
a b&c=d/é?😀a%20b%26c%3Dd%2F%C3%A9%3F%F0%9F%98%80a%20b&c=d/%C3%A9?%F0%9F%98%80
https://x.com/a b?q=a&b=c#h éeverything, including : and /https://x.com/a%20b?q=a&b=c#h%20%C3%A9

encodeURI keeps ; , / ? : @ & = + $ # because they're meaningful in a URL. That's exactly why it's wrong for a value: &, =, / and ? in your data pass straight through and change the URL's structure. encodeURIComponent escapes all of them. Neither escapes A-Z a-z 0-9 - _ . ! ~ * ' ( ), so an apostrophe or parenthesis in a value survives (it's (ok)*!~ stays it's%20(ok)*!~). That's valid, but it bites if you later put the URL inside a single-quoted shell string or an HTML attribute.

The rule I actually follow

// values go through encodeURIComponent, structure is typed by you
const url =
  "https://api.example.com/search?q=" + encodeURIComponent(query) +
  "&lang=" + encodeURIComponent(lang);

// better: let the platform do it
const url2 = new URL("https://api.example.com/search");
url2.searchParams.set("q", query);
url2.searchParams.set("lang", lang);

encodeURI is for the case where someone hands you a mostly-valid URL with raw spaces or non-ASCII characters in it and you want it to be sendable. Even then, new URL(str).href does the same job and understands the parts. It turned https://x.com/a b?q=a b into https://x.com/a%20b?q=a%20b.

The + vs %20 trap

Here's the one that causes silent data corruption. Two different specs are in play:

  • RFC 3986 (generic URI syntax) knows only percent-encoding. A space is %20. A literal + is just a plus sign.
  • application/x-www-form-urlencoded (HTML forms, defined by the WHATWG URL standard) encodes a space as +, and a literal plus as %2B.

Query strings in practice follow the form rules, because that's what browsers produce and most servers parse. Paths don't. Watch what the same string does in each:

Where you decodea+b%2Bc becomes
new URLSearchParams("q=a+b%2Bc").get("q")a b+c
decodeURIComponent("a+b%2Bc")a+b+c

URLSearchParams treats the first + as a space. decodeURIComponent doesn't know about that convention and leaves it as a plus. So if you decode a query string by hand with decodeURIComponent, every space that arrived as + stays a +. And going the other way, URLSearchParams serializes the value a b+c as a+b%2Bc, while encodeURIComponent gives a%20b%2Bc. Both are valid for a query; only one is valid for a path.

Gotcha: a + in a path segment is a literal plus. /files/a+b.pdf is the file a+b.pdf, not a b.pdf. Use %20 there, always.

The practical consequence is for emails and phone numbers in query strings. A user whose address is jane+news@example.com sends jane+news@example.com unencoded, the server applies form decoding, and the mail goes to jane news@example.com. Encode the value with encodeURIComponent (%2B) and both decoders agree.

Double-encoding, the other classic

Percent signs are themselves encoded, so encoding twice turns %41 into %2541:

encodeURIComponent("%41");   // "%2541"
decodeURIComponent("%2541"); // "%41" — one layer off, not "A"

It usually happens when a value is encoded in one layer (a helper, an HTTP client) and again by you. The telltale sign in logs is %25 showing up right before two hex digits: %2520 is an encoded %20, i.e. a space that went through the wringer twice. The fix is to find the layer that's already doing it, not to add a decode.

The reverse also exists: a redirect parameter like ?next=/a?b=1&c=2 without encoding, where &c=2 becomes a top-level parameter of your URL and next gets truncated. This is how some open-redirect and signature-mismatch bugs begin, so encode the value, then validate the decoded result.

Three mistakes that look like they work

  1. Encoding the entire URL with encodeURIComponent. You get https%3A%2F%2Fx.com%2F..., which is fine as a value inside another URL's query and wrong as a URL.
  2. Calling decodeURIComponent on untrusted input without a try/catch. A truncated sequence like %E0%A4%A throws URIError: URI malformed, and so does encodeURIComponent on a lone surrogate such as "\ud800". A bad link shouldn't be able to crash your request handler.
  3. Hand-rolling a replace chain. str.replace(" ", "%20") replaces only the first space, and it ignores non-ASCII bytes entirely. Use the built-ins.

When I need to see what a URL really contains, I paste it into the URL encoder/decoder to check each layer, or into the URL parser to see which part a character landed in. Seeing %2520 split out is faster than counting percent signs by eye.

FAQ

Should I use encodeURI or encodeURIComponent?

encodeURIComponent for any piece of data going into a URL. encodeURI only when you're normalizing a complete URL and know the structure is already right.

Is a space + or %20?

%20 is safe everywhere. + means a space only inside a form-encoded query string, so use it only if you're deliberately producing form encoding, and never in a path.

Does encoding handle emoji and accented characters?

Yes. Both functions convert to UTF-8 first, so é becomes %C3%A9 and 😀 becomes %F0%9F%98%80. Make sure the receiving side decodes as UTF-8, not Latin-1, or you'll see é instead.

Do I need to encode the fragment?

Rarely on the server side, since it never leaves the browser, but yes if client code reads it and may contain & or =. The same rules apply.

References

Primary documentation and specifications checked when this article was last updated.

URLsJavaScriptHTTPFundamentals

Related articles

All articles