btoa and atob: Latin-1 only
btoa (“binary to ASCII”) encodes a string where every character is treated as one byte. atob reverses it. That works for ASCII:
btoa("hello"); // "aGVsbG8="
atob("aGVsbG8="); // "hello"
It breaks on anything outside code points 0–255:
btoa("héllo ✓");
// InvalidCharacterError: Failed to execute 'btoa' on 'Window':
// The string to be encoded contains characters outside of the Latin1 range.
Worse, characters that fit in Latin-1 encode without an error, but as the wrong bytes. btoa("héllo") returns aOlsbG8= (Latin-1 é = 0xE9), while every other language and the Base64 encoder produce the UTF-8 version aMOpbGxv.
btoa and atob also exist in Node.js 16+ as legacy globals, but on the server the Buffer API is faster and has a built-in base64url encoding. See Base64 in Node.js.
Encode and decode UTF-8 strings
The portable fix is to turn the string into UTF-8 bytes with TextEncoder, then feed those bytes to btoa as a “binary string”:
function encodeUtf8(str) {
const bytes = new TextEncoder().encode(str);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
function decodeUtf8(b64) {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
encodeUtf8("héllo wörld"); // "aMOpbGxvIHfDtnJsZA=="
decodeUtf8("aMOpbGxvIHfDtnJsZA=="); // "héllo wörld"
atob("aMOpbGxvIHfDtnJsZA=="); // "héllo wörld" (no TextDecoder)
The old btoa(unescape(encodeURIComponent(str))) trick gives the same result but relies on the deprecated unescape.
Uint8Array.toBase64 and fromBase64
The newer API works on bytes directly, with no binary-string step. It is supported in Chrome and Edge 140+, Firefox 133+ and Safari 18.2+.
const bytes = new TextEncoder().encode("héllo wörld");
bytes.toBase64(); // "aMOpbGxvIHfDtnJsZA=="
new TextDecoder().decode(Uint8Array.fromBase64("aMOpbGxvIHfDtnJsZA=="));
// "héllo wörld"
There is also setFromBase64, which decodes into an existing buffer and reports how much it used:
const target = new Uint8Array(8);
target.setFromBase64("aGVsbG8K"); // { read: 8, written: 6 }
Feature-detect before relying on it: typeof Uint8Array.prototype.toBase64 === "function". The Uint8Array.toBase64 guide covers every option.
URL-safe Base64 with and without padding
With the new API, pass options:
const d = new TextEncoder().encode("<<???>>");
d.toBase64({ alphabet: "base64url" }); // "PDw_Pz8-Pg=="
d.toBase64({ alphabet: "base64url", omitPadding: true }); // "PDw_Pz8-Pg"
Uint8Array.fromBase64("PDw_Pz8-Pg", { alphabet: "base64url" }); // bytes of "<<???>>"
With btoa/atob, translate the alphabet yourself:
const toB64Url = b64 =>
b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const fromB64Url = s => {
s = s.replace(/-/g, "+").replace(/_/g, "/");
return s + "=".repeat((4 - s.length % 4) % 4);
};
toB64Url(btoa("<<???>>")); // "PDw_Pz8-Pg"
atob(fromB64Url("PDw_Pz8-Pg")); // "<<???>>"
This is what JWTs use; see Base64 vs Base64URL or paste a token into the JWT decoder.
Files, Blobs and large data
To read a file the user picked, FileReader.readAsDataURL returns a data URI. Strip everything up to the comma for plain Base64:
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result; // "data:text/plain;base64,aGVsbG8K"
const b64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
};
reader.readAsDataURL(file);
With the new API it is shorter: new Uint8Array(await file.arrayBuffer()).toBase64().
To turn Base64 back into a downloadable file, decode to bytes and wrap them in a Blob:
const bytes = Uint8Array.fromBase64(b64);
const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
For big buffers, never spread them into String.fromCharCode(...bytes). With 500,000 bytes Chromium throws RangeError: Maximum call stack size exceeded. Build the binary string in slices instead:
function bytesToBase64(bytes) {
let bin = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(bin);
}
// 5,000,000 bytes -> 6,666,668 characters
The output is about a third larger than the input; the size overhead guide has the exact formula.
Validation and error messages
atob ignores ASCII whitespace and tolerates missing padding, so atob(" aG k= ") and atob("aGk") both return "hi". It rejects characters outside the standard alphabet:
atob("PDw_Pz8-Pg");
// InvalidCharacterError: Failed to execute 'atob' on 'Window':
// The string to be decoded is not correctly encoded.
fromBase64 throws SyntaxError and can be made stricter with lastChunkHandling:
Uint8Array.fromBase64("PDw_Pz8-Pg");
// SyntaxError: Found a character that cannot be part of a valid base64 string.
Uint8Array.fromBase64("aGl="); // 2 bytes, accepted
Uint8Array.fromBase64("aGl=", { lastChunkHandling: "strict" });
// SyntaxError: The base64 input terminates with non-zero padding bits.
The default, "loose", accepts missing padding. "strict" requires correct padding and zero padding bits; "stop-before-partial" leaves an incomplete final chunk for streaming decoders. Message text differs between browsers, but the error names are standard. For a visual check, the Base64 validator highlights the exact problem, and common Base64 errors lists the causes.
FAQ
Why does btoa throw InvalidCharacterError?
btoa accepts only characters in the Latin-1 range (code points 0–255). Any emoji, Cyrillic or CJK character makes it throw. Convert the string to UTF-8 bytes with TextEncoder first, or use Uint8Array.prototype.toBase64().
Can I use Uint8Array.toBase64 in production?
It ships in current Chrome, Edge, Firefox and Safari, but not in older browsers or in Node.js 24. Feature-detect with typeof Uint8Array.prototype.toBase64 === "function" and fall back to the btoa helper. Details in Uint8Array.toBase64 explained.
Why does atob return strange characters like é instead of é?
atob returns one character per byte, so a two-byte UTF-8 sequence becomes two Latin-1 characters. Pass the result through Uint8Array.from(bin, c => c.charCodeAt(0)) and TextDecoder to get proper text.
How do I decode Base64URL with atob?
Replace - with + and _ with /, then pad with = to a multiple of 4 characters. With the new API, call Uint8Array.fromBase64(s, { alphabet: "base64url" }) and skip both steps.