Reserved vs unreserved: what actually needs encoding
RFC 3986 splits characters into unreserved — letters, digits, -, ., _, ~ — which never need encoding, and reserved — : / ? # [ ] @ ! $ & ' ( ) * + , ; = — which carry structural meaning and must be encoded when used as data. The subtlety is “when used as data”: a / separating path segments stays raw, but a / inside a single segment (a filename) must become %2F. Encoding is therefore per-component, never applied blindly to a whole assembled URL — encoding the full URL would destroy the very separators that make it a URL.
Building query strings that don’t break
Each key and each value must be encoded independently before assembly with = and &. The classic failure: a value containing & or = (a song title, a search phrase) injected raw splits the query string and silently truncates data. In JavaScript, URLSearchParams handles this correctly and is preferred over manual encodeURIComponent chains; in URLs you craft by hand, paste each value through this encoder separately. Watch for # especially — unencoded, it ends the query string and starts a fragment that never reaches the server.
Non-ASCII text: UTF-8 first, then percent
Modern encoding is two steps: text becomes UTF-8 bytes, each byte becomes %XX. Turkish ş is %C5%9F, one character but two encoded bytes. Legacy systems that encoded with ISO-8859 charsets produce different sequences for the same character — the root cause of mojibake in old query strings. International domain names take a different path entirely: the hostname uses Punycode (bücher.de → xn--bcher-kva.de), while only paths and queries use percent-encoding.
Spotting encoding bugs in the wild
Symptoms map to causes: literal %20 displayed to users means decoded-never; %2520 in logs means encoded-twice; broken parameters after a user typed & means encoded-never on input; mojibake like ç for ç means UTF-8 bytes decoded as Latin-1. Paste the suspect string here and decode step by step — each decode peels one layer, and the layer count tells you which component in your pipeline is misbehaving.