InvalidCharacterError: Failed to Execute 'btoa' — Every Cause and Fix
InvalidCharacterError: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range is the error you get when btoa() meets a character it can't represent. What makes it confusing is that it's inconsistent: the exact same-looking piece of text throws on one input and encodes silently on another — and the ones that don't throw aren't necessarily safe either. This guide covers what "Latin1 range" actually means, the two most common real-world triggers, the quieter failure mode that doesn't throw at all, and the fix — each verified live against the real browser btoa() implementation, with a prefilled Base64 Suite example for each.
btoa() only accepts a "binary string" — every UTF-16 code unit in the input must be between 0 and 255. Curly quotes, an em dash, an emoji, or CJK/Cyrillic/Arabic text are all above that range and throw immediately. Accented Latin letters like é, ñ, and ü are inside that range, so they don't throw — but the result is the raw UTF-16 code unit, not a UTF-8 byte, so anything that later decodes it as UTF-8 gets the wrong character back. Both problems have the same fix: run the string through TextEncoder before calling btoa().
Quick reference
Verified against real Chromium, not assumed — each input below was run through the browser's own btoa() via Playwright against this site's live Base64 Suite build.
| Input | What happens | Why |
|---|---|---|
Curly quotes, em dash “ ” — | Throws InvalidCharacterError | Code points 8220/8221/8212, all > 255 |
| Emoji, CJK, Cyrillic, Arabic | Throws InvalidCharacterError | Code points far above 255 (emoji are also surrogate pairs) |
Currency signs € | Throws InvalidCharacterError | U+20AC = 8364 |
Accented Latin é ñ ü, £ | Succeeds, but output is not valid UTF-8 | Code points 0–255, inside range, but not the character's UTF-8 byte(s) |
| Plain ASCII | Succeeds, correct | Code points 0–127, both Latin1 and UTF-8-identical |
What "the Latin1 range" actually means
btoa() predates Unicode-aware JavaScript. It was written to encode a "binary string" — a JS string being used as a stand-in for a byte array, where each character's code unit (0–65535 in a normal JS string) is supposed to already be a single byte (0–255). It doesn't know anything about UTF-8, UTF-16, or what a "character" is semantically — it just checks whether every code unit fits in a byte, and throws the moment one doesn't.
That check is stricter than "ASCII" (0–127) but looser than "safe." It's exactly the 256 code points of ISO-8859-1 (Latin1), which is why the error names it specifically. Two different kinds of real-world text land on opposite sides of that boundary in ways that aren't obvious from looking at them.
1. Copy-pasted rich text — the most common trigger
Word, Google Docs, Notion, and most word processors silently convert straight quotes and hyphens into "smart" typography as you type: " becomes “ or ”, ' becomes ’, and -- becomes an em dash —. None of that is visible at a glance in a rendered document, but all three are Unicode characters above U+00FF. Paste a sentence copied from any of those tools into a form field that gets Base64-encoded, and it breaks on text that looks completely ordinary.
btoa("Ready to ship — final review");
// InvalidCharacterError: Failed to execute 'btoa' on 'Window':
// The string to be encoded contains characters outside of the Latin1 range.
The visible text gives no clue why — there's no obviously "foreign" character in it. The em dash is the only non-Latin1 character in the whole string.
See it handled live
This is the exact string above, pasted with real curly quotes and an em dash. Base64 Suite runs it through the fix below internally, so unlike a raw btoa() call it encodes correctly instead of throwing.
2. Emoji and non-Latin scripts
The more obviously "this text isn't ASCII" case: emoji, CJK characters, Cyrillic, Arabic, Hebrew — anything with a code point well above 255. Emoji specifically are encoded in JavaScript as surrogate pairs (two UTF-16 code units, each still far outside the Latin1 range), so a single emoji reacts the same way a whole sentence of Cyrillic would.
btoa("🎉 New feature shipped!");
// InvalidCharacterError: Failed to execute 'btoa' on 'Window':
// The string to be encoded contains characters outside of the Latin1 range.
This is the version of the bug that shows up the moment a product adds emoji reactions, display names in non-Latin scripts, or any user-generated text field that isn't restricted to ASCII — which, for most products, is most user-generated text.
See it handled live
Open this example in Base64 Suite3. Base64-encoding a JSON payload
A specific, very common shape of the first two causes: encoding a JSON blob (a config object passed through a URL parameter, a signed request body, a feature-flag payload) that happens to contain a name, a comment, or an emoji reaction inside one of its string values. The JSON.stringify() call succeeds fine — JSON has no problem with Unicode — and the failure only shows up one step later, at the btoa() call, which makes it easy to misdiagnose as a JSON bug when it isn't one.
const payload = JSON.stringify({ user: "José", status: "🎉 approved" });
btoa(payload);
// InvalidCharacterError: Failed to execute 'btoa' on 'Window':
// The string to be encoded contains characters outside of the Latin1 range.
Note that in this example it's the emoji that breaks it — José alone (see the next section) wouldn't have thrown.
See it handled live
Open this example in Base64 Suite4. The quiet failure: text that doesn't throw and is still wrong
This is the case that causes more real damage than the error, precisely because there's no error. Accented Latin letters — é, ñ, ü, ç — and symbols like £ all have code points under 256, so btoa() accepts them without complaint:
btoa("José García");
// "Sm9z6SBHYXJj7WE=" — no error, looks like perfectly normal Base64.
The subtlety is what those bytes mean. btoa() takes each UTF-16 code unit — 233 for é — and encodes that raw number as if it were already a byte. It never asked whether 233 is what é looks like in UTF-8 (it isn't — UTF-8 represents é as the two bytes 0xC3 0xA9, not the single byte 0xE9). The Base64 comes out looking completely normal. The corruption only appears the moment something on the other end decodes it assuming UTF-8, which is what nearly everything does by default:
const naive = btoa("café"); // "Y2Fm6Q==" — no error
const bytes = Uint8Array.from(atob(naive), c => c.charCodeAt(0));
new TextDecoder().decode(bytes); // "caf�" — silently wrong
Verified live: that exact snippet, run in Chromium against this site's own build, decodes café back as caf� — a real replacement character, not a typo in this article. If that Base64 string ever crosses a network boundary — sent to a server, stored in a database, passed to a language other than JavaScript — whatever receives it and treats it as UTF-8 (which is nearly everything) will mangle the same character every single time, with nothing in the logs to say why.
See it handled live
Base64 Suite encodes and decodes this string correctly — round-tripping through Encode, Swap, then Decode returns the exact original text, because it never takes the naive path above.
Open this example in Base64 SuiteThe fix: convert to UTF-8 bytes first
Both failure modes have the same root cause — btoa() operating on UTF-16 code units instead of bytes — and the same fix, which is MDN's own documented workaround: convert the string to real UTF-8 bytes with TextEncoder, turn those bytes into a Latin1-safe binary string, and only then call btoa(). Decoding reverses the same three steps.
function encodeUnicodeToBase64(str) {
const bytes = new TextEncoder().encode(str);
const binString = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
return btoa(binString);
}
function decodeBase64ToUnicode(b64) {
const binString = atob(b64);
const bytes = Uint8Array.from(binString, (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
encodeUnicodeToBase64("🎉 José — approved"); // never throws
decodeBase64ToUnicode(encodeUnicodeToBase64("🎉 José — approved"));
// "🎉 José — approved" — exact round trip, every time
This works because every UTF-8 byte is, by definition, in the 0–255 range btoa() requires — so it never throws, on any input, including emoji and full CJK/Cyrillic/Arabic text. And because the bytes are real UTF-8 rather than raw code units, anything downstream that decodes the Base64 as UTF-8 gets the original text back exactly, instead of the silent mismatch in the previous section. This is exactly what Base64 Suite's own Encode and Decode buttons do internally, which is why none of the live examples above ever throw or corrupt, even though a raw btoa() call on the same input does.
Debugging checklist
- If you got a thrown
InvalidCharacterError, the input has at least one character above code point 255 — check for copy-pasted smart quotes/em dashes first, since those are the least visually obvious cause. - If
btoa()succeeded but the decoded result looks corrupted or has a�in it, the input almost certainly had an accented Latin character and was decoded as UTF-8 somewhere downstream. - Don't fix this by stripping non-ASCII characters before encoding — that discards real data. Convert to UTF-8 bytes with
TextEncoderinstead of avoiding the characters. - If you're decoding Base64 that was produced by a naive
btoa()call elsewhere (not the fix above), decode it as Latin1 bytes, not UTF-8 — trying to "fix" already-corrupted data with a UTF-8 decoder won't recover it.
📦 Encode or decode Unicode text without either failure mode
Base64 Suite runs the UTF-8-safe conversion above on every Encode and Decode, entirely client-side — curly quotes, emoji, and accented text all round-trip correctly, and nothing you type is ever uploaded.
Open Base64 Suite →