URIError: URI Malformed — Every Real Cause and How to Fix It

Published 12 Sep 2026 · 7 min read

Somewhere in a request pipeline, decodeURIComponent() (or decodeURI, encodeURI, encodeURIComponent) hits a string it can't process and throws URIError: URI malformed instead of returning anything. There's no partial result, no default fallback — just a hard stop, often deep inside a router or middleware layer where the string that broke it isn't even visible in the stack trace. This walkthrough covers five real causes, verified directly against a real JavaScript engine rather than assumed from the spec, with a live, prefilled URL Tool example for each so you can watch the exact string fail instead of reading a description of one.

Quick answer: On decode, URIError: URI malformed almost always means a percent-encoded sequence in the string is incomplete, uses an invalid hex digit, or doesn't form valid UTF-8 once decoded. On encode, it means the string contains a lone UTF-16 surrogate half with no partner. The fix is never in decodeURIComponent itself — it's in whatever produced the string before it got there.

Quick reference

All five results below were re-verified directly in a real JavaScript engine before publishing — not assumed from documentation.

SymptomMost likely causeFix
Fails on values built by hand-concatenating strings, never on ones from URLSearchParamsA raw, unencoded % reached decodeEncode with encodeURIComponent() at the point the value is built, not just before sending
Fails intermittently, only on values a router or framework already touchedDouble-decoding in the request pipelineDecode exactly once, at exactly one layer — check what your router already does before decoding again
Fails only on long URLs, or ones that were copy-pasted or loggedA percent-encoded sequence got truncated at a byte boundaryNever slice a percent-encoded string by raw character count; truncate the decoded text instead, then re-encode
Fails only on strings from an old system, database export, or CSVBytes were percent-encoded as Latin-1/Windows-1252, not UTF-8Convert to UTF-8 before percent-encoding, or decode with the correct legacy charset instead of decodeURIComponent
Fails on one specific URL that was recently hand-editedA corrupted or mistyped hex digit in a %XX sequenceFix the typo, or rebuild the value from source instead of editing an already-encoded string

1. A raw, unencoded % reaches decode

The single most common trigger: a query string or path segment built by hand-concatenating strings — "note=" + userText — instead of going through URLSearchParams or encodeURIComponent. Any literal % in the original text survives untouched into the final string, and the moment something downstream tries to decode that string, the bare % (followed by whatever ordinary character comes next, almost never two valid hex digits) breaks the parse.

// Bug: built by string concatenation, never encoded
const note = 'cost is 20% less';
const query = 'id=42¬e=' + note; // the % survives as a literal character

decodeURIComponent(query);
// URIError: URI malformed

The fix has to happen where the value is built, not where it's decoded: run it through encodeURIComponent() (or build the query string with URLSearchParams, which encodes automatically) before it's ever concatenated into a URL or query string.

See it fail live

This is the exact string from the example above — a query value with a literal, never-encoded % in it:

Open this example in URL Tool

2. Double-decoding in a request pipeline

Many frameworks and routers already decode query parameters and path segments before your code ever sees them — Express's req.query, most router libraries' matched params, and browsers' own URL/URLSearchParams APIs all hand back already-decoded values. If application code decodes that value again — often because it was copy-pasted from another code path that genuinely needed to decode a raw string — any character that legitimately decoded to a literal % the first time (from a properly escaped %25) blows up on the second pass.

// The wire value was "SAVE20%25" (the literal query string on the
// request). The router already decoded that once, correctly, at the
// framework layer, before your code ever sees req.query.coupon.
const coupon = req.query.coupon; // "SAVE20%" — already decoded

// Bug: decoding a value that's already been decoded once
const clean = decodeURIComponent(coupon);
// URIError: URI malformed — the literal "%" from the FIRST decode
// isn't valid percent-encoding on a second pass

The fix is procedural, not a code change to decodeURIComponent itself: trace exactly where in the pipeline decoding already happens — router, middleware, a shared utility — and decode at exactly one of those layers, never more than one.

See it fail live

This is a value as it looks after a framework's own first decode — a coupon code containing a literal %. Decoding it again, as buggy app code would, is exactly what throws:

Open this example in URL Tool

3. A percent-encoded sequence truncated at a byte boundary

Multi-byte UTF-8 characters — accented letters, most non-Latin scripts, emoji — take two, three, or four %XX triplets once percent-encoded. Cut that encoded string anywhere except a triplet boundary — a copy-paste that stopped mid-line, a chat client wrapping a long URL, or a naive str.slice(0, maxLength) written to shorten a URL for a log line or a share link — and you're left with a dangling % or an incomplete UTF-8 byte sequence.

const full = 'https://example.com/search?q=' + encodeURIComponent('café');
// -> "https://example.com/search?q=caf%C3%A9"  (é is 2 bytes: %C3%A9)

// Bug: truncated by raw character count, cutting the last hex digit
const truncated = full.slice(0, full.length - 1);
// -> "https://example.com/search?q=caf%C3%A"

decodeURIComponent(truncated);
// URIError: URI malformed

The fix: never truncate an already-percent-encoded string by character count. Decode first, truncate the real text (ideally by code point, via Array.from(text).slice(0, n).join(''), so you don't split a surrogate pair either), then re-encode the shortened result.

See it fail live

The exact truncated URL from the example above — the last hex digit of café's encoded é was cut off:

Open this example in URL Tool

4. Bytes were percent-encoded as Latin-1/Windows-1252, not UTF-8

decodeURIComponent always assumes the bytes behind each %XX form valid UTF-8. Older systems — legacy databases, ASP-era codebases, spreadsheet exports — often stored text as Latin-1 or Windows-1252 and percent-encoded those raw bytes directly, without ever converting to UTF-8 first. A Windows-1252 smart quote, for example, is a single byte (0x92) that percent-encodes to %92 — which, read as UTF-8, is a stray continuation byte with no valid leading byte in front of it.

// A Windows-1252 right single quotation mark ('), byte 0x92,
// percent-encoded directly without converting to UTF-8 first
const legacyValue = 'name=Bob%92s%20Diner';

decodeURIComponent(legacyValue);
// URIError: URI malformed — %92 alone isn't valid UTF-8

The fix has to happen at the source: convert the original bytes to UTF-8 before percent-encoding them, or if you're stuck consuming values you didn't produce, decode the raw bytes with the correct legacy charset (e.g. a TextDecoder('windows-1252') over the raw byte values) instead of assuming UTF-8.

See it fail live

A restaurant name containing a Windows-1252 smart quote, percent-encoded as a raw byte rather than converted to UTF-8 first:

Open this example in URL Tool

5. A corrupted or mistyped hex digit

The most avoidable version: an already-encoded URL gets hand-edited — a redaction tool blacks out part of a logged URL and leaves the % markers behind, someone fixes a typo directly in an encoded string instead of the source value, or a template-string placeholder gets substituted incorrectly. Either way, one hex digit in a %XX sequence ends up invalid or missing, and decoding fails on that single triplet.

// Bug: a template placeholder was substituted with a stray character
// instead of a real hex digit
const redirect = 'redirect=%2Ghome'; // should have been %2Fhome ("/")

decodeURIComponent(redirect);
// URIError: URI malformed — "G" isn't a valid hex digit

The tell here is different from the other four: it's usually one specific URL, not a whole class of requests, and it often traces back to a recent manual edit rather than a code change. The fix is to correct the digit or, better, rebuild the value from its original source instead of hand-editing already-encoded text.

See it fail live

A redirect target where a template substitution left an invalid hex digit in place of a real one:

Open this example in URL Tool

Debugging checklist

  1. Isolate exactly which value throws — URIError gives you the exception, not the offending string, so wrap the suspect decode call in its own try/catch and log the input before it fails.
  2. Check whether the value came from hand-built string concatenation rather than URLSearchParams or encodeURIComponent — that's cause #1, and it's the most common by far.
  3. If the value already looks decoded (readable text, no %XX sequences except an occasional literal %), suspect double-decoding before anything else — check what your router or framework already does.
  4. If it only fails on long or copy-pasted URLs, check for truncation at a non-triplet boundary — cause #3.
  5. If it only fails on values from one specific old system or export, suspect a legacy Latin-1/Windows-1252 byte encoding rather than UTF-8 — cause #4.
  6. If it's one specific URL that was recently hand-edited, look for a single corrupted hex digit before assuming a systemic bug — cause #5.

This is a different failure mode from a JSON parse error or an unexpected end of JSON input — those happen after a string has already been successfully decoded, when a different parser tries to make sense of its contents. URIError happens earlier, before there's even a string to hand to anything else. For the encoding fundamentals this piece assumes, see the URL Encoding & Decoding Guide.

🔗 Paste a URL or query string and see exactly where it breaks

URL Tool encodes, decodes, and deconstructs URLs entirely in your browser using the same functions covered in this post — nothing you paste in is ever uploaded.

Open URL Tool →

← Back to Blog · See also: URL Encoding & Decoding Guide →