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 InvalidCharacterError

The 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.

More on this tool