encodeURI vs encodeURIComponent: Which One and Why It Matters
encodeURI leaves & and = alone, so it corrupts query values. Which to use per URL part, and why + is not always a space.
Table of contents
- Key takeaways
- What is the difference between encodeURI and encodeURIComponent?
- Which function goes where?
- Why is + sometimes a space and sometimes a plus?
- What about the characters neither function escapes?
- Why does decodeURIComponent throw, and what should I do about it?
- Double encoding, and how to recognise it
- A short decision procedure
- Frequently asked questions
- Should I use encodeURI or encodeURIComponent for a query parameter?
- Why does my URL show %20 instead of a space?
- Is + a valid way to encode a space?
- Does encodeURIComponent make a value safe to insert into HTML?
- Why does decodeURIComponent throw instead of returning the input?
- Related reading
- References
Two functions, nearly the same name, and picking the wrong one produces a URL that looks correct, passes review, and quietly loses part of its data. The difference is one sentence long: encodeURI leaves the characters that delimit a URL alone, and encodeURIComponent does not.
That sentence is also the whole bug. If a value you are inserting contains &, =, ? or #, encodeURI will hand it to the parser as structure rather than as text.
Key takeaways#
encodeURIComponentfor values,encodeURIfor a whole URL you already trust.encodeURIdoes not escape; / ? : @ & = + $ , #, which is exactly why it corrupts a query value.- Neither function escapes
! ' ( ) *, though RFC 3986 reserves them. - In a query string,
+means a space. In a path segment,+is a literal plus sign. decodeURIComponentthrows on a malformed escape. Unguarded, that is a 500 on a URL anyone can type.
What is the difference between encodeURI and encodeURIComponent?#
encodeURIComponent escapes everything except A–Z a–z 0–9 - _ . ! ~ * ' ( ). encodeURI escapes the same set plus the URI syntax characters ; / ? : @ & = + $ , #, meaning it leaves those eleven alone. So encodeURI preserves structure, and encodeURIComponent destroys it — which is the correct behaviour for a value.
MDN puts the rule plainly: encodeURI "is used to encode a URL as a whole, assuming it is already well-formed. If you want to dynamically assemble string values into a URL, you probably want to use encodeURIComponent on each dynamic segment instead."
Here is the failure, using MDN's own example:
const name = "Ben & Jerry's";
// Broken: the & is left as a delimiter
const bad = encodeURI(`https://example.com/?choice=${name}`);
[...new URL(bad).searchParams];
// [['choice', 'Ben '], [" Jerry's", '']] <- two params, both wrong
// Correct: the value is encoded, the structure is not
const good = `https://example.com/?choice=${encodeURIComponent(name)}`;
[...new URL(good).searchParams];
// [['choice', "Ben & Jerry's"]]The broken version does not throw, does not warn, and returns a string that is a perfectly valid URL. It just means something else.
Which function goes where?#
Match the function to the part of the URL you are building, not to the shape of the data.
| URL part | Use | Why |
|---|---|---|
| A query parameter value | URLSearchParams | Handles escaping and & joining for you |
| A single path segment | encodeURIComponent | A / in the value must not become a separator |
| A fragment value | encodeURIComponent | A # in the value would truncate it |
| A complete, trusted URL | encodeURI | Escapes spaces without breaking structure |
| Anything user-supplied | encodeURIComponent | Assume it contains a delimiter |
In practice the first row wins most of the time. URLSearchParams builds the whole query correctly, so reaching for encodeURIComponent per value is a sign you are assembling a string where you could be assembling a URL:
const url = new URL('https://example.com/search');
url.searchParams.set('q', "Ben & Jerry's");
url.searchParams.set('page', '2');
url.toString();
// https://example.com/search?q=Ben+%26+Jerry%27s&page=2Why is + sometimes a space and sometimes a plus?#
Because two different encodings share the same percent-escape syntax. In application/x-www-form-urlencoded — the format of an HTML form submission and, by convention, of query strings — a space is serialised as +. Everywhere else in a URL, including every path segment, + is a literal plus character.
The WHATWG URL specification names the switch explicitly: "If spaceAsPlus is true and byte is 0x20 (SP), then append U+002B (+) to output", and spaceAsPlus is true only for the urlencoded percent-encode set.
So the same three bytes mean different things depending on position:
/search?q=a+b -> q is "a b"
/files/a+b.txt -> the filename is literally "a+b.txt"This is the source of a whole family of bugs. A value round-tripped through the wrong decoder turns C++ into C , or turns a base64 string containing + into one containing spaces, which then fails to decode. If you are debugging a token or a signature that works locally and fails behind a proxy, this is the first thing to check — and the reason base64url exists at all, since it swaps + and / for - and _ precisely to survive a URL.
encodeURIComponent always writes %20 for a space, never +. URLSearchParams always writes +. Both are right; they are encoding different things.
What about the characters neither function escapes?#
! ' ( ) * pass through both functions unescaped, and RFC 3986 reserves them. MDN notes that the RFC "reserves !, ', (, ), and *, even though these characters have no formalized URI delimiting uses."
In practice this rarely breaks a browser, and regularly breaks something else: an OAuth signature that canonicalises differently, an S3 key, a strict server-side parser, or a Markdown link where an unescaped ) closes the URL early. If you need strict RFC 3986 output, escape them yourself:
function encodeRFC3986(value) {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}There is one more failure mode on the encode side. encodeURIComponent throws URIError on a lone surrogate — half of an emoji pair, which is easy to produce by truncating a string to a fixed length:
encodeURIComponent('\uD800'); // URIError: malformed URI sequenceString.prototype.toWellFormed() replaces lone surrogates with U+FFFD, which makes the encode safe. Truncating on code points rather than UTF-16 units avoids creating the problem in the first place.
Why does decodeURIComponent throw, and what should I do about it?#
Because a percent sign is a promise about the next two characters, and %E0%A4%A breaks it. decodeURIComponent treats a malformed escape as unrecoverable and throws URIError, so any code path that decodes a URL segment without a guard converts a URL anyone can type into a server error.
This is not hypothetical. This site shipped it: a blog tag route called decodeURIComponent on its slug, and /blog/tag/%E0%A4%A returned a 500 while every other route answered the same input with a 400. One try block was the fix.
function safeDecode(segment) {
try {
return decodeURIComponent(segment);
} catch {
return null; // a malformed escape is not a value; treat it as a miss
}
}Two rules that follow from it:
- Decode exactly once, at the boundary. Every framework already decodes route params. Decoding again is how
%2520becomes%20becomes a space, and how a value containing a literal%gets mangled. - Never decode to make a comparison work. If two forms of a URL should be one page, canonicalise and redirect. Accepting both silently gives you two URLs serving identical content, which is a duplicate-content problem rather than a robustness feature.
Double encoding, and how to recognise it#
A value encoded twice looks like this, and the giveaway is %25 — the escape for % itself:
once: Ben%20%26%20Jerry%27s
twice: Ben%2520%2526%2520Jerry%2527sIt happens when a value is encoded on the way into a template that encodes again, or when a proxy re-encodes a path it has already decoded. The symptom is a literal %20 appearing in rendered output where a space belongs.
Diagnosing it is faster than reasoning about it. Paste the value into the URL decoder and count how many passes it takes to reach readable text: one is correct, two means something in the chain is encoding a value that was already encoded. The URL encoder is the other half, and it is worth using to confirm what a given input should look like before you go looking for who mangled it.
A short decision procedure#
- Building a query? Use
URLSearchParams. Stop here. - Inserting into a path or fragment?
encodeURIComponenton each segment. - Escaping a URL you already trust, whole?
encodeURI. - Need strict RFC 3986?
encodeURIComponentplus the!'()*replace above. - Decoding anything from a request? Wrap it in
try/catch, once, at the boundary.
Step 3 is the rarest and the one encodeURI exists for. If you cannot say why the URL is already well-formed, you want step 2.
Frequently asked questions#
Should I use encodeURI or encodeURIComponent for a query parameter?#
encodeURIComponent, or better, URLSearchParams. encodeURI does not escape & or =, so a value containing either splits into extra parameters and the original value is lost without any error being raised.
Why does my URL show %20 instead of a space?#
Something decoded it one time too few, or encoded it one time too many. Check for %2520 in the raw value: that is %20 re-encoded, and it means two layers of encoding were applied where one was intended.
Is + a valid way to encode a space?#
Only in application/x-www-form-urlencoded content, which includes query strings by convention. In a path segment + is a literal plus, so a filename containing + must be written %2B if it is not to be misread.
Does encodeURIComponent make a value safe to insert into HTML?#
No. It makes a value safe for a URL. HTML needs HTML escaping, and JavaScript contexts need their own. A value that has been percent-encoded can still carry an XSS payload once it is decoded into markup.
Why does decodeURIComponent throw instead of returning the input?#
Because a truncated escape has no correct interpretation, and silently returning the raw string would let malformed input flow onward looking valid. Catch it and treat it as a miss.
Related reading#
- Base64 Is Not Encryption — including why
base64urlswaps+and/ - Decoding a JWT Is Not Verifying It — where base64url shows up on every request
- Common JSON Errors
- HTML Escaping Is Not XSS Protection — the other four contexts
- Why Every Tool Gives You a Different Character Count — where lone surrogates come from
- Round-trip a value with the URL Encoder and URL Decoder, and check a slug with the Slug Generator.
References#
Tags
- JavaScript
- Url Encoding
- HTTP
- Debugging
