URIError: URI Malformed — Every Real Cause and How to Fix It
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.
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.
| Symptom | Most likely cause | Fix |
|---|---|---|
Fails on values built by hand-concatenating strings, never on ones from URLSearchParams | A raw, unencoded % reached decode | Encode with encodeURIComponent() at the point the value is built, not just before sending |
| Fails intermittently, only on values a router or framework already touched | Double-decoding in the request pipeline | Decode 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 logged | A percent-encoded sequence got truncated at a byte boundary | Never 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 CSV | Bytes were percent-encoded as Latin-1/Windows-1252, not UTF-8 | Convert 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-edited | A corrupted or mistyped hex digit in a %XX sequence | Fix 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:
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:
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:
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 Tool5. 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 ToolDebugging checklist
- Isolate exactly which value throws —
URIErrorgives 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. - Check whether the value came from hand-built string concatenation rather than
URLSearchParamsorencodeURIComponent— that's cause #1, and it's the most common by far. - If the value already looks decoded (readable text, no
%XXsequences except an occasional literal%), suspect double-decoding before anything else — check what your router or framework already does. - If it only fails on long or copy-pasted URLs, check for truncation at a non-triplet boundary — cause #3.
- 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.
- 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 →