Base64 Encode and Decode in the Browser: atob(), btoa(), UTF-8 and Uint8Array.toBase64()
Every browser has had `atob()` and `btoa()` for Base64 since the early days of the web, so you rarely need a library. But the two functions were designed for "binary strings" in which each character is one byte, which makes them fail, or silently produce the wrong result, as soon as text contains anything outside Latin-1. Since 2025 all major browsers also support `Uint8Array.toBase64()` and `Uint8Array.fromBase64()`, which work on bytes directly. This guide covers both, with output from real runs.
atob and btoa in one minute
btoa() ("binary to ASCII") encodes, atob() ("ASCII to binary") decodes. For plain ASCII they do exactly what you expect:
btoa("hello"); // 'aGVsbG8='
atob("aGVsbG8="); // 'hello'
Both are defined in the HTML standard and are available on window and in Web Workers. Node.js has offered them as globals since version 16, but there Buffer is the idiomatic tool. The names are easy to swap, so a mnemonic helps: the result of btoa is ASCII text you can put in JSON or a URL.
Why btoa fails on UTF-8 text
btoa() accepts only characters with code points from 0 to 255, because it treats each character as one byte. Anything above throws:
btoa("café ✓");
// InvalidCharacterError: Failed to execute 'btoa' on 'Window':
// The string to be encoded contains characters outside of the Latin1 range.
The more dangerous case is the one that does not throw. é is U+00E9, inside Latin-1, so btoa("café") returns Y2Fm6Q==. That is the Latin-1 byte E9, not the UTF-8 bytes C3 A9 that every server, Python script and command-line tool expects for "café". The receiving side then decodes garbage, or rejects the input as invalid UTF-8. The mirror image happens on decoding: atob("Y2Fmw6k="), which is correct UTF-8 Base64 for "café", returns "café", because atob gives you one character per byte without decoding UTF-8.
The UTF-8-safe way with TextEncoder
The fix is to convert explicitly between text and bytes. TextEncoder produces UTF-8 bytes, which you then pass to btoa as a binary string; TextDecoder reverses it:
function encodeBase64(text) {
const bytes = new TextEncoder().encode(text);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
function decodeBase64(b64) {
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
encodeBase64("café ✓"); // 'Y2Fmw6kg4pyT'
decodeBase64("Y2Fmw6kg4pyT"); // 'café ✓'
The output matches Buffer.from("café ✓").toString("base64") in Node and base64 on the command line, which is the real test of correctness. You may still find the old idiom btoa(unescape(encodeURIComponent(s))) in tutorials; it works, but unescape is deprecated, and the TextEncoder version states its intent clearly.
Uint8Array.toBase64() and fromBase64()
The modern API removes the binary-string detour entirely. It works on bytes, supports the URL-safe alphabet, and can omit padding. According to MDN's compatibility data it is available in Chrome 140, Firefox 133, Safari 18.2 and Node.js 25, which makes it Baseline 2025:
const bytes = new TextEncoder().encode("café ✓");
bytes.toBase64(); // 'Y2Fmw6kg4pyT'
bytes.toBase64({ alphabet: "base64url", omitPadding: true });
new TextDecoder().decode(Uint8Array.fromBase64("Y2Fmw6kg4pyT")); // 'café ✓'
Uint8Array.fromBase64("aGk_Pz4-", { alphabet: "base64url" }); // Uint8Array(6)
// feature detection for older browsers and Node 24 and earlier
const hasNative = typeof Uint8Array.prototype.toBase64 === "function";
fromBase64 is strict about the alphabet: given URL-safe input without the option, it throws SyntaxError: Found a character that cannot be part of a valid base64 string. That is better than atob, which also throws InvalidCharacterError on - and _ but gives you no way to accept them. With atob, convert first: replace - with + and _ with /. Note too that atob is forgiving about whitespace and missing padding (atob("aGVsbG8") is "hello"), so do not use it to validate input.
Large binary data and the call stack
A common shortcut for bytes is btoa(String.fromCharCode(...bytes)). It is fine for small arrays, but spreading puts every byte on the call stack as an argument, and for a 200 KB array it fails with RangeError: Maximum call stack size exceeded. Process the bytes in chunks when you cannot use toBase64():
function bytesToBase64(bytes) {
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
Keep in mind that Base64 makes data a third larger, and the intermediate strings exist in memory at the same time as the bytes. For multi-megabyte files, upload them as binary with fetch and a Blob or FormData body instead of embedding Base64 in JSON.
Files, images and data URLs
For a file chosen by the user, FileReader.readAsDataURL() returns a data URL whose payload is Base64; remove the prefix up to the comma to get the bare Base64 string. Going the other way, fetch() can read a data URL, which is the simplest way to turn Base64 back into a Blob:
// file input -> Base64
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result; // "data:image/png;base64,iVBORw0..."
const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
};
reader.readAsDataURL(input.files[0]);
// Base64 -> Blob
const blob = await (await fetch(`data:image/png;base64,${base64}`)).blob();
img.src = URL.createObjectURL(blob);
Object URLs created with URL.createObjectURL keep the blob in memory until you call URL.revokeObjectURL() or the page unloads, so revoke them when they are no longer needed. To check a value by hand, the Base64 encoder and decoder runs entirely in the browser and handles UTF-8 correctly; decoding Base64 audio covers media payloads in more detail.
Sources and further reading
The WHATWG HTML Standard defines atob() and btoa() and the forgiving-base64 decoding they use. The TC39 "Uint8Array to/from base64" proposal, now part of ECMAScript, specifies toBase64, fromBase64 and their options; MDN documents them with browser compatibility data. The WHATWG Encoding Standard defines TextEncoder and TextDecoder, and the File API defines FileReader and object URLs. RFC 4648 specifies Base64 and Base64url.
Frequently asked questions
How do I Base64 encode a string in JavaScript in the browser?
For ASCII text, btoa(text) is enough. For any text that may contain non-ASCII characters, encode it to UTF-8 bytes with TextEncoder first, or use new TextEncoder().encode(text).toBase64() in current browsers.
Why does btoa throw InvalidCharacterError?
btoa only accepts characters with code points up to 255, because it treats each character as one byte. Emoji, Cyrillic, Chinese and many symbols are outside that range, so convert the text to UTF-8 bytes before encoding.
Why does atob return strange characters like é?
atob returns one character per byte and does not decode UTF-8, so a two-byte character such as é appears as two characters. Pass the result through TextDecoder to get the original text.
What is Uint8Array.toBase64()?
A built-in method that encodes bytes to Base64, with options for the URL-safe alphabet and omitting padding. It is supported in Chrome 140, Firefox 133, Safari 18.2 and Node.js 25; Uint8Array.fromBase64() decodes.
How do I decode URL-safe Base64 in the browser?
Use Uint8Array.fromBase64(text, { alphabet: "base64url" }) where available. With atob, first replace - with + and _ with /, then decode.
How do I convert a file to Base64 in the browser?
Read it with FileReader.readAsDataURL() and take the part of the result after the comma. For large files, prefer uploading the binary Blob directly rather than sending Base64.