What actually has to be escaped
RFC 3986 splits the character set three ways. Unreserved characters — A-Z, a-z, 0-9, and the four punctuation marks - . _ ~ — are always safe and never need escaping. Reserved characters are the ones with structural meaning: : / ? # [ ] @ for the shape of the URL and ! $ & ' ( ) * + , ; = as sub-delimiters inside components. Everything else, including spaces and every character outside ASCII, has to be written as %XX per byte. Since URLs carry UTF-8 in practice, one non-Latin character often becomes three escape sequences; an emoji becomes four. The address bar showing you readable characters is display sugar — the request on the wire carries the escaped form. Nothing you paste here is sent anywhere; the conversion happens in the page.
Component scope versus whole-URL scope
encodeURIComponent escapes every reserved character, so a value containing &, =, ? or / comes out inert and cannot break out of the slot you are putting it in. That is what you want for anything going after key=. encodeURI assumes you are handing it a complete address and deliberately leaves the reserved set alone, escaping only spaces, non-ASCII and characters that are illegal anywhere. Use it to repair a URL someone typed with a space in the path; never use it on a value. The classic failure is a search term containing an ampersand run through encodeURI: the ampersand survives, the server reads it as a separator, and half the search term arrives as a parameter nobody defined.
Spaces: %20 or a plus sign
Both appear in the wild and they come from different specifications. In the URL grammar a space is %20, full stop. In application/x-www-form-urlencoded — the format an HTML form produces on submit — a space is +, and a literal plus has to be written %2B. That is a media type that happens to live in the query string, not a rule about URLs, which is why search engines show + in their query strings while REST APIs show %20. Most server frameworks decode both as a space when parsing a query string, so the ambiguity rarely bites there. It does bite in a path segment, where + is simply a plus sign, and it bites in values that legitimately contain a plus: phone numbers with a country code and formulas both come back wrong if a form decoder gets hold of them.
Reading a double-encoded string
Encoding an already-encoded string escapes the percent signs themselves, so %E2%82%AC becomes %25E2%2582%25AC and one decode pass leaves you back at the encoded form rather than the original. A field full of %25 is the signature, and it almost always means two layers of code each helpfully encoded the same value — a client that escaped a parameter and a framework that escaped the whole URL it was pasted into. The fix is to find the layer that should not be encoding rather than to decode twice on the way out, because the second decode will also unescape any percent sign the user genuinely typed. If you have inherited data in that state, decoding twice is a migration, not a runtime strategy.
Questions people ask
Decoding throws an error on a string that looks fine.
Two causes account for nearly all of them. The first is a bare percent sign in the input, as in a value like "up 20% today" that was never encoded; % has to be followed by two hex digits and "20%" leaves a dangling one at the end. The second is a truncated copy that cut a multi-byte character in half, leaving a %E2 with nothing to follow it. Neither string was ever valid percent encoding, so there is no correct decoding of it.
What about strings that look like %uD55C?
That is output from the deprecated escape() function, which predates the current specification and was never standardised for URLs. Nothing decodes it as a URL, including this tool. If you control the code producing it, switch to encodeURIComponent; if you only have the data, the %u sequences are UTF-16 code units and need a purpose-written converter rather than a URL decoder.
Do I need to encode a URL I am putting inside another URL?
Yes, with component scope, and this is the case people most often get wrong. A redirect target like https://example.com/next?a=1 placed after ?return= contains a colon, two slashes, a question mark and an equals sign, all of which the receiving parser will read as structure. Component encoding turns them into %3A, %2F, %3F and %3D so the whole thing arrives as one opaque value.