Why btoa breaks on UTF-8 (and how to Base64 Unicode)
btoa and atob treat each character as one byte (0–255). Real text is UTF-8. Characters outside Latin-1 throw, or worse, silently wrong bytes if you force them.
What goes wrong
Emoji, most non-Latin scripts, and many symbols need more than one byte in UTF-8. btoa never UTF-8-encodes; it wants a “binary string”.
btoa('café'); // works in some engines (é is Latin-1)
btoa('hello 👋'); // throws InvalidCharacterErrorThe correct browser pattern
Encode to bytes with TextEncoder, then Base64 those bytes. Decode with Base64 → bytes → TextDecoder.
const bytes = new TextEncoder().encode('hello 👋');
const b64 = btoa(String.fromCharCode(...bytes));
// or: bytesToBase64(bytes) without spreading large arrays
const again = new TextDecoder().decode(
Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
);Node
Buffer.from('hello 👋', 'utf8').toString('base64');
Buffer.from(b64, 'base64').toString('utf8');What ToolDeck does
The Base64 encode and decode tools always treat text as UTF-8. File mode hashes the file bytes as they are. Nothing is uploaded.
Questions
- Why did another site encode my string differently?
- It may have used UTF-16 code units, Latin-1 truncation, or a different newline. Agree on UTF-8 bytes first.
- Is percent-encoding the same problem?
- URL encoding also needs a byte encoding first. Browsers use UTF-8 for
encodeURIComponent.
See also
Related tool: Base64 encode. Runs in your browser; input is not uploaded.