A URL looks like plain text, but it is not free text. It is a structured identifier with a strict grammar and a fixed set of characters that carry meaning: the ? that starts a query, the & that separates parameters, the / that separates path segments. When your data contains one of those characters, or a space, or a non-ASCII byte, you cannot just drop it in. You have to encode it. Percent-encoding is how that works, and misunderstanding it is behind a large share of “the link works locally but breaks in production” bugs.
What percent-encoding actually is
Percent-encoding, also called URL encoding, replaces a byte with a % followed by two hexadecimal digits that spell out that byte’s value. A space is byte 0x20, so it becomes %20. An ampersand is 0x26, so it becomes %26. The rule is mechanical: take the byte, write it as two hex digits, prefix a percent sign. %41 is A, because 0x41 is 65, the ASCII code for capital A. You will rarely encode A, but the mechanism is identical for every byte.
The point of the scheme is to let any byte travel inside a URL without colliding with the characters that structure the URL. Once a value is percent-encoded, the parser on the other end can split on ? and & safely, then decode each piece back to its original bytes.
Reserved versus unreserved characters
RFC 3986 sorts characters into groups. The unreserved set is the ASCII letters A-Z and a-z, the digits 0-9, and four symbols: -, _, ., and ~. These never need encoding. They mean the same thing encoded or not, so a correct encoder leaves them alone.
Reserved characters are the ones with structural meaning: : / ? # [ ] @ ! $ & ' ( ) * + , ; =. These are the delimiters. Whether you encode a reserved character depends on where it sits. A / in a path is a separator and stays raw. A / inside a single query value is data and must become %2F, or the parser reads it as structure. Everything outside the unreserved and reserved sets, including spaces, control characters, and non-ASCII bytes, is unsafe and must be encoded.
Why spaces become %20, and the + confusion
A space cannot appear literally in a URL, so it is encoded. In the path and in most contexts that means %20. In one specific context, the body or query string of an HTML form submitted as application/x-www-form-urlencoded, a space is written as + instead. This is the source of endless confusion: %20 and + can both mean space, but + only means space in form-encoded data. Decode a query string with the wrong rule and a+b turns into either a b or a+b, and one of those is a bug. When in doubt, encode spaces as %20; it is valid everywhere. Only decode + as a space when you know the data is form-encoded.
Whole URL versus a single value
This is the distinction that trips up most JavaScript developers. encodeURI is built to encode a complete URL, so it deliberately leaves the structural characters : / ? # & = untouched. encodeURIComponent is built to encode one piece of a URL, a single query value or path segment, so it encodes those structural characters too.
Use the wrong one and you get silent corruption. If you build "https://x.com/search?q=" + query and run the whole thing through encodeURI, an & inside the query stays raw and splits your parameter in two. The fix is to encode only the value with encodeURIComponent:
const url = "https://x.com/search?q=" + encodeURIComponent(query);
Rule of thumb: encode each value with encodeURIComponent, then assemble the URL from those pieces. Reach for encodeURI only when you already hold a full URL and just want to make it legal.
UTF-8 handling
Percent-encoding operates on bytes, not characters, so a non-ASCII character is encoded by first converting it to its UTF-8 bytes and then percent-encoding each byte. The character é is UTF-8 bytes 0xC3 0xA9, so it encodes to %C3%A9: two escapes for one character. An emoji can take four escapes. Modern encoders assume UTF-8, and so does the decoder. If the two sides disagree on encoding, you get mojibake. The safe default everywhere is UTF-8.
Double-encoding bugs
The nastiest URL bug is encoding something twice. Encode a space once and you get %20. Encode that again and the % itself becomes %25, producing %2520. Now the value carries the literal text %20 instead of a space, and no single decode pass recovers the original.
This happens when a value is encoded at more than one layer: the frontend encodes it, then a backend helper encodes the already-encoded string again. The tell is %25 showing up where you expected a plain %, or a visible %2520 in your logs. The fix is to own encoding at exactly one layer and pass raw values everywhere else.
Check it yourself
Paste any string into the URL encoder to see its percent-encoded form and decode it back. To pull an existing URL apart into scheme, host, path, and query, use the URL parser. When you assemble campaign links, the UTM builder encodes each parameter value for you, so you never guess which characters are reserved.