decodeURIComponent(text) turns every %XX escape back into the bytes it stands for and reads them as UTF-8. It’s the inverse of encodeURIComponent, and it decodes everything, including %26 (&), %2F (/) and %3F (?).
decodeURIComponent('Tom%20%26%20Jerry%2F50%25%20off%3F'); // 'Tom & Jerry/50% off?'
decodeURIComponent('caf%C3%A9'); // 'café'
A + is a plus unless it’s form data
decodeURIComponent leaves + alone, because in a URL a plus is a plus. Only application/x-www-form-urlencoded data, which HTML forms and URLSearchParams write, uses + for a space:
const text = 'Tom+%26+Jerry%2F50%25+off%3F';
decodeURIComponent(text); // 'Tom+&+Jerry/50%+off?'
decodeURIComponent(text.replace(/\+/g, ' ')); // 'Tom & Jerry/50% off?'
new URLSearchParams('q=' + text).get('q'); // 'Tom & Jerry/50% off?'
Replace the pluses before decoding, never after, or an encoded plus (%2B) turns into a space too. The tool below shows that text both ways: Decoded keeps the +, As a form sends it reads it as a space.
It throws on a bad escape
A % that isn’t followed by two hex digits, or escapes that don’t add up to valid UTF-8, make decodeURIComponent throw:
decodeURIComponent('100%'); // URIError: URI malformed
decodeURIComponent('%E0%A4'); // URIError: URI malformed (a 3-byte character cut short)
User-typed text and half-encoded links hit this. Catch it where you decode:
const safeDecode = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
URLSearchParams never throws: it keeps a stray % as written and turns bytes that aren’t UTF-8 into � (U+FFFD). The tool below names the bad escape and its position instead.
decodeURI leaves reserved escapes
decodeURI is the inverse of encodeURI, so it won’t decode an escape for a character that shapes a URL: decodeURI('Tom%20%26%20Jerry%2F50%25%20off%3F') gives 'Tom %26 Jerry%2F50% off%3F'. Use it for display, not for reading values.
Read query strings with URL
const url = new URL('https://example.com/search?q=caf%C3%A9+%26+cake&tag=a&tag=b&empty=');
url.searchParams.get('q'); // 'café & cake'
url.searchParams.getAll('tag'); // ['a', 'b']
url.searchParams.get('empty'); // ''
url.searchParams.get('missing'); // null
Object.fromEntries(url.searchParams); // { q: 'café & cake', tag: 'b', empty: '' }: the last tag wins
Decode once. 'a%2520b' means a%20b, but run through decodeURIComponent twice it becomes 'a b'.