Why JavaScript btoa() Throws Invalid Character Errors with Emojis (and the Modern Fix)

The Mistake Almost Every Developer Makes First

You have a string. It contains an emoji. You want to Base64-encode it. So you do the obvious thing:

btoa('Hello 🌍')

The browser throws this at you:

Uncaught DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.

Frustrating? Absolutely. But here's the thing β€” btoa() isn't broken. It was never designed to handle what you're feeding it. Most developers hit this wall, then copy-paste the first Stack Overflow snippet they find, which often introduces subtle bugs that only surface weeks later in production when a user submits an unusual emoji or a CJK character.

Let's walk through this the right way. No guesswork. No cargo-culted code. Just a concrete, step-by-step path from broken to bulletproof.

Step 1 β€” Understand What btoa() Actually Accepts (Hint: Not Unicode)

Before fixing anything, you need to know why it breaks. The btoa() function β€” short for "binary to ASCII" β€” was introduced in early browser specs with a specific constraint: it only accepts strings where every character has a code point between 0 and 255. That's the Latin1 range (ISO-8859-1). One byte per character. That's it.

The Latin1 Ceiling

Here's the math. Latin1 covers code points U+0000 through U+00FF. That's 256 possible values. Anything above U+00FF triggers the InvalidCharacterError.

Now consider the emoji 🌍. Its Unicode code point is U+1F30D. That's 79,949 in decimal β€” far beyond the 255 ceiling. In JavaScript, this character is represented as a UTF-16 surrogate pair: two 16-bit code units (\uD83C\uDF0D). Neither surrogate falls within Latin1. So btoa() rejects them.

Why "Just Remove the Emoji" Is Not a Fix

Some developers strip non-Latin1 characters before encoding. That's a data-loss strategy, not a solution. If you're building a Base64 utility β€” whether it's an online encoder tool, an API endpoint, or a client-side data pipeline β€” silently dropping user input is a bug waiting to be reported.

Step 2 β€” Stop Trying to "Fix" It with encodeURIComponent (The Dead End)

The most upvoted Stack Overflow answer for this problem looks something like this:

btoa(encodeURIComponent(str))

And the corresponding decode:

decodeURIComponent(atob(encoded))

This works. Sometimes. For some characters. But it has a critical flaw that bites you in production.

The Double-Encoding Problem

encodeURIComponent('🌍') produces %F0%9F%8C%8D. That's 12 ASCII characters. When you pass that to btoa(), it encodes those 12 ASCII characters β€” not the original 4 UTF-8 bytes of the emoji. Your Base64 output is now larger than it needs to be, and it's not interoperable with any standard Base64 decoder that expects UTF-8 input.

For a single emoji, the difference seems trivial. But if you're encoding a 10 KB JSON payload with 50 emojis scattered throughout, you're inflating your output by roughly 200% compared to proper UTF-8 Base64 encoding. That matters for API payloads, localStorage quotas, and data URIs.

When It Completely Falls Apart

Worse, encodeURIComponent doesn't escape certain characters like !, *, ', (, and ). These are all within Latin1, so btoa() accepts them. But if your string contains a % followed by characters that look like a hex escape sequence, decodeURIComponent may misinterpret them during decoding. This is a rare but real corruption scenario.

Verdict: skip this approach entirely if you're building anything production-grade.

Step 3 β€” Convert Your String to UTF-8 Bytes First

Here's where the modern fix begins. The core insight: btoa() works perfectly with binary data β€” you just need to get your Unicode string into a binary format first.

The right tool for this is TextEncoder. It's a built-in browser API that converts JavaScript strings into UTF-8 byte sequences. It's been supported in all modern browsers since around 2017 and is available in Node.js (as a global) since version 11.

The Conversion, Step by Step

Here's exactly what happens when you encode '🌍' with TextEncoder:

  1. new TextEncoder().encode('🌍') returns a Uint8Array with 4 elements.
  2. Those elements are [240, 159, 140, 141] β€” the UTF-8 byte representation of U+1F30D.
  3. In hex: 0xF0 0x9F 0x8C 0x8D.

Each of those byte values (0–255) fits within the Latin1 range. That's the key. You've transformed an un-encodable Unicode string into a sequence of values that btoa() can handle.

Step 4 β€” Encode the Byte Array, Not the String

Now you have a Uint8Array. But btoa() expects a string, not a typed array. You need one more step: convert each byte back into a single-character string, then concatenate them.

Here's the complete, modern encoding function:

function encodeBase64(str) { const bytes = new TextEncoder().encode(str); let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); }

Let's trace through this with 'Hello 🌍':

  1. TextEncoder produces 10 bytes: [72, 101, 108, 108, 111, 32, 240, 159, 140, 141] β€” "Hello " is 6 ASCII bytes, the emoji adds 4 UTF-8 bytes.
  2. The loop builds a 10-character string where each character's code point matches its byte value.
  3. btoa() encodes those 10 characters into SGVsbG8g8J+MjQ==.

That output is standard Base64. It's 16 characters long (Base64 encodes 3 bytes into 4 characters, rounded up with padding). Any compliant Base64 decoder β€” in Python, Go, Rust, or any other language β€” will decode it back to the original UTF-8 bytes.

Performance Note for Large Inputs

The string concatenation loop is fine for most use cases. But if you're encoding megabytes of data, use String.fromCharCode.apply(null, bytes) in chunks, or spread the array: String.fromCharCode(...bytes). The spread approach hits a call stack limit around 65,536 arguments in V8, so chunk it for large payloads.

Step 5 β€” Decode It Back Correctly

Encoding is only half the job. If you're building a Base64 utility, your users will need to decode too. The reverse process mirrors the encoding steps.

Here's the matching decode function:

function decodeBase64(encoded) { const binary = atob(encoded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return new TextDecoder().decode(bytes); }

Step by step:

  1. atob() decodes the Base64 string back into a binary string (each character is one byte, code point 0–255).
  2. You allocate a Uint8Array of the same length.
  3. The loop copies each character's code point into the byte array.
  4. TextDecoder interprets those bytes as UTF-8 and reconstructs the original string β€” emojis and all.

Test it: decodeBase64('SGVsbG8g8J+MjQ==') returns Hello 🌍. Exact round-trip. No data loss.

Step 6 β€” Your Production-Ready Checklist

If you're shipping a Base64 utility β€” whether it's a web tool, an npm package, or an internal helper β€” run through this checklist before you call it done.

Encoding Checklist

  • Use TextEncoder β€” never pass raw Unicode strings to btoa().
  • Skip encodeURIComponent β€” it inflates output and breaks interoperability.
  • Handle large inputs in chunks β€” avoid stack overflow on arrays over 65K elements.
  • Test with edge cases β€” emoji (4-byte UTF-8), CJK characters (3-byte), Cyrillic (2-byte), and plain ASCII (1-byte) should all round-trip correctly.
  • Verify output length β€” for input of N bytes, Base64 output should be 4 * ceil(N / 3) characters including padding.

Decoding Checklist

  • Use TextDecoder β€” never pass atob() output directly to string operations that assume Unicode.
  • Validate input before atob() β€” strip whitespace and verify the character set is valid Base64 (A–Z, a–z, 0–9, +, /, =).
  • Catch InvalidCharacterError β€” malformed Base64 will throw. Wrap atob() in try/catch and return a meaningful error.
  • Test round-trip fidelity β€” encode then decode, and assert the output matches the input byte-for-byte.

The One-Liner Modern Alternative

If you're targeting modern browsers or Node.js 16+, there's an even cleaner approach using Buffer (Node.js) or the Uint8Array to Base64 shortcut:

Node.js: Buffer.from(str, 'utf-8').toString('base64')

Browser (modern): btoa(String.fromCharCode(...new TextEncoder().encode(str)))

Both produce identical, standards-compliant output. Pick the one that fits your runtime.

Final Reality Check

The btoa() function isn't hostile to Unicode β€” it's simply agnostic to it. It operates on bytes, and JavaScript strings aren't bytes. They're UTF-16 code unit sequences. The moment you understand that distinction, the fix becomes obvious: convert to bytes first, encode second, decode third, convert back to string fourth.

Four steps. No surprises. No data loss. Every emoji, every character, every time.