Stop Double-Encoding Your URLs: A Field Guide

There's a category of bug that doesn't crash your application. It doesn't throw an exception. It doesn't even show up in your error logs most of the time. Instead, it silently corrupts data in transit — a query parameter arrives mangled, an API call returns a 404 for a resource that definitely exists, or a redirect lands on a broken page. Double-encoding is that bug, and it's more common than anyone in the industry likes to admit.

Let me be direct: if you've ever called encodeURIComponent on a string that was already encoded, or handed an assembled URL to a function that encodes the whole thing again, you've introduced this problem. This guide will make sure you understand exactly why it happens, how to catch it, and — crucially — when each encoding function actually belongs.

The Two Functions You're Probably Confusing

JavaScript ships with two URL-encoding utilities that look similar but operate at completely different layers of a URI's structure. Understanding the difference isn't optional; it's foundational.

encodeURIComponent(str) encodes a piece of a URL. It escapes everything except the unreserved characters: letters, digits, -, _, ., and ~. That means it will encode slashes, ampersands, equals signs, question marks — all the structural characters that give a URL its meaning. That's intentional. When you're encoding a query parameter value that might contain an ampersand, you absolutely want & to become %26, or the server will misparse your parameter boundary.

encodeURI(str) encodes a complete URL. It intentionally skips the structural characters — :, /, ?, #, [, ], @, !, $, &, ', (, ), *, +, ,, ;, = — because those characters are load-bearing. Encoding them would break the URL's structure.

The trap is that these two functions are not interchangeable, and neither is a general-purpose "make this safe" button. Using the wrong one at the wrong stage is the root cause of virtually every double-encoding incident I've diagnosed.

What Double-Encoding Actually Looks Like

Say you have a search feature that accepts free-text queries. A user searches for "C++ & algorithms". You correctly encode that for a query string:

const query = encodeURIComponent("C++ & algorithms");
// → "C%2B%2B%20%26%20algorithms"

You assemble the URL: https://example.com/search?q=C%2B%2B%20%26%20algorithms

Now imagine you hand that assembled URL to a library or a wrapper function that calls encodeURI or even encodeURIComponent on the whole string before making the request. Now your percent signs get encoded:

"C%2B%2B%20%26%20algorithms"
→ "C%252B%252B%2520%2526%2520algorithms"

The server receives %25 (the encoding for a literal percent sign), decodes it to %, and hands your application the string C%2B%2B %26 algorithms — not C++ & algorithms. The data is corrupted, silently, with no error in sight.

Now scale that to an OAuth redirect URI containing an already-encoded callback URL, or a webhook endpoint where the payload URL has been signed and encoded, and you start to see how damaging this can get in production systems.

Where the Real Danger Lives: Composed URLs

Single-function misuse is understandable. The more insidious problem is composition — layering encoding across function calls, libraries, and abstraction boundaries.

Consider a common pattern in backend code:

function buildRedirectUrl(baseUrl, token, callbackUrl) {
  return `${baseUrl}?token=${token}&callback=${encodeURIComponent(callbackUrl)}`;
}

// Later, someone logs this URL and also passes it through
// an HTTP client that auto-encodes query strings...

This breaks the moment any HTTP client or routing layer believes it's receiving an unencoded URL and helpfully encodes it again. The original developer encoded callbackUrl correctly, but the caller didn't know that — or the library author assumed inputs were always raw strings.

This is fundamentally a contract problem. Every function that accepts a URL-shaped string should document clearly: does it expect a raw string, a partially encoded string, or a fully encoded string? In most codebases I've worked in, this contract is unwritten, and that's where the bugs breed.

The Correct Mental Model: Encode Late, Encode Once

Here's the principle that eliminates double-encoding almost entirely: encode data values at the last possible moment, and only once.

Work with raw, unencoded strings throughout your application logic. Assemble your URL as late as possible — ideally in a single dedicated function — and encode each component exactly once at that point:

function buildSearchUrl(baseUrl, params) {
  const queryString = Object.entries(params)
    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
    .join("&");
  return `${baseUrl}?${queryString}`;
}

// Raw strings all the way in:
buildSearchUrl("https://example.com/search", {
  q: "C++ & algorithms",
  lang: "en"
});
// → "https://example.com/search?q=C%2B%2B%20%26%20algorithms&lang=en"

The encoded form exists only as the final output. Nothing touches it again before it goes on the wire.

If you're using the browser's URL API or Node's URL class, you get this for free. Setting url.searchParams.set("q", "C++ & algorithms") handles encoding correctly and won't double-encode if you call url.toString() afterward. The URLSearchParams interface is specifically designed to manage this complexity — use it over manual string concatenation whenever you can.

Detecting Double-Encoding in Existing Code

If you suspect you have a double-encoding problem in a live system, the telltale signature is %25 appearing in values that should contain percent-encoded characters. A URL parameter containing %2520 (the encoding of %20) is a guaranteed double-encode. Search your logs for that pattern.

On the decoding side, double-decoding is the equivalent mistake and produces different but equally confusing failures. If you call decodeURIComponent on a string that was never encoded in the first place, you'll throw a URIError the moment you hit a stray percent sign — say, in a user-supplied string containing a percentage like "50% off". Always know the encoding state of your input before you try to decode it.

A useful debugging approach: add a middleware layer (or a thin wrapper around your HTTP client) that logs the raw URL string immediately before dispatch. Compare that to what the server receives. If those differ, something in your stack is encoding after you think you're done.

Library-Specific Gotchas Worth Knowing

Axios serializes params objects using its own serializer, which calls encodeURIComponent internally. If you pre-encode a query value and then pass the whole string as a param value, it will double-encode. Either pass raw values to params, or build the URL string yourself and pass it directly with no params key.

fetch does not encode anything automatically. You own the URL string completely. This is the safest default from a double-encoding standpoint, but it means you must encode everything correctly yourself.

Express.js decodes path parameters and query strings automatically via decodeURIComponent before they reach your route handlers. If you're receiving already-decoded values in req.query and then encoding them to build a downstream URL, that's the correct pattern. Where it breaks is when someone passes a pre-encoded value through a query param that Express decodes, and then the application re-encodes the resulting string (which now contains literal plus signs or percent signs from the original encoding) before sending it onward.

Python's urllib.parse.urlencode encodes values automatically. Pass raw strings. Similarly, the requests library will encode params dict values — don't pre-encode them.

One Special Case: Path Segments

Query parameters get the most attention, but path segments have their own encoding rules. A resource identifier like a username might legitimately contain a forward slash — say, a GitHub-style org/repo path. If you encode that with encodeURIComponent, the slash becomes %2F. Whether your server accepts %2F in a path segment or treats it as a literal slash depends on server configuration; many web servers normalize %2F back to / before routing, which can completely change which route matches.

The correct approach is to structure your API to avoid slashes in path parameters where possible, or to explicitly document and test how %2F is handled at every layer — load balancer, web server, and application framework.

The Takeaway

URL encoding is not a safety ritual you sprinkle over strings to make them "URL-safe." It's a precise operation that maps data values into specific positions within a URI structure, and it must happen exactly once per value, at the right layer, using the right function for the right context.

Use encodeURIComponent on individual values before composing them into a URL. Use encodeURI only on a complete URL that contains no pre-encoded components and no characters that need structural encoding. Use the URL and URLSearchParams APIs whenever your environment provides them. And treat "this string came from somewhere else" as a reason to verify its encoding state before touching it, not a reason to encode it again just to be safe.

The safe play is always raw strings in, one encode out. Everything else is technical debt with a timestamp on it.