Uint8Array.prototype.toBase64() and Uint8Array.fromBase64() encode and decode binary data natively, with no btoa string juggling and no Node Buffer. They support base64url and unpadded output through options, throw a SyntaxError on bad input instead of guessing, and ship in Chrome 140, Firefox 133, Safari 18.2 and Node.js 25 onwards.
The same proposal added toHex() and fromHex(), plus setFromBase64() and setFromHex() for writing into a buffer you already own. The proposal reached stage 4 at TC39 and lands in the ES2026 specification. MDN marks the methods as Baseline 2025, newly available since September 2025, when Chrome 140 shipped them.
What was wrong with btoa and atob?
btoa() takes a string, not bytes. It treats each UTF-16 code unit as a byte, so anything above U+00FF throws InvalidCharacterError. That is why so many codebases carry a helper like this:
function bytesToBase64(bytes) {
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}
It works by building a “binary string” that exists only to be thrown away. The shorter version, btoa(String.fromCharCode(...bytes)), spreads every byte into a function argument and dies with RangeError: Maximum call stack size exceeded once the array gets into the hundreds of kilobytes. Then there is URL-safe base64, which btoa has never known about, so you chain three replace() calls to swap + for -, / for _ and strip the = padding.
The MDN page for btoa now points readers at toBase64() for exactly this reason. If you have bytes, encode bytes.
How does Uint8Array toBase64 work?
Encoding is a method on the array. Decoding is a static method on the constructor that returns a fresh Uint8Array.
const bytes = new Uint8Array([251, 255, 254, 1]);
bytes.toBase64();
// "+//+AQ=="
bytes.toBase64({ alphabet: "base64url", omitPadding: true });
// "-__-AQ"
const text = new TextEncoder().encode("price: 20 GBP");
const encoded = text.toBase64();
// "cHJpY2U6IDIwIEdCUA=="
new TextDecoder().decode(Uint8Array.fromBase64(encoded));
// "price: 20 GBP"
Text goes through TextEncoder and TextDecoder on the way in and out, because base64 encodes bytes and a JavaScript string is not bytes. (Our post on why JavaScript string length lies about emoji covers the UTF-16 side of that in more depth.)
toBase64() takes two options. alphabet is "base64" (the default, using + and /) or "base64url" (using - and _). omitPadding defaults to false; set it to true to drop the trailing = characters, which is what JWTs, WebAuthn and most URL parameters expect.
fromBase64() takes alphabet too. Its second option, lastChunkHandling, decides what happens when the input length is not a multiple of four:
"loose", the default, accepts a final chunk of two or three characters without padding, and ignores stray bits at the end."strict"requires correct=padding and insists the leftover bits are zero. Use it when the encoded form matters, such as comparing signatures."stop-before-partial"decodes complete chunks and ignores a trailing partial one, which is the building block for streaming.
ASCII whitespace inside the string is skipped, so a base64 blob wrapped at 76 columns from a PEM file or an email body decodes without a replace(/\s/g, "") first.
Is fromBase64 stricter than Buffer.from?
Yes, and that strictness is the best reason to switch in Node code too. Node’s Buffer docs say the 'base64' encoding accepts the URL-safe alphabet as well as the standard one and ignores whitespace. What they do not promise is an error on garbage. Buffer.from() hands back bytes whatever you give it:
Buffer.from("aGVsbG8@@@d29ybGQ=", "base64");
// <Buffer 68 65 6c 6c 6f 1d db dc 9b 19>
Uint8Array.fromBase64("aGVsbG8@@@d29ybGQ=");
// SyntaxError: Found a character that cannot be part of a valid base64 string.
The first call returns ten bytes, five of them nonsense, and your code carries on. The second fails where the bad data arrived. For anything parsed from a request (a webhook signature, a token segment, an uploaded file) the loud failure is the one you want.
The alphabet is also explicit. fromBase64() with the default alphabet rejects - and _; if the input is base64url, you say so. That removes the bug where a lenient decoder accepts either alphabet, so a token encoded with the wrong one passes every local test and fails at a stricter consumer.
Where does this replace real code?
Three common spots get shorter straight away.
Hashing to hex. The old idiom maps every byte through toString(16).padStart(2, "0"). Now:
const data = new TextEncoder().encode("hello");
const digest = await crypto.subtle.digest("SHA-256", data);
const hex = new Uint8Array(digest).toHex();
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
Reading a JWT payload. The segment is unpadded base64url, which loose handling accepts:
const [, payload] = token.split(".");
const claims = JSON.parse(
new TextDecoder().decode(
Uint8Array.fromBase64(payload, { alphabet: "base64url" }),
),
);
That reads the claims; it does not verify the signature. Verification still belongs to a JWT library or crypto.subtle.verify.
Webhook and passkey plumbing. HMAC signatures such as the ones in our Shopify webhook verification walkthrough arrive as standard base64, and WebAuthn challenges and credential IDs travel as base64url, as in our passkeys in Node.js guide. Both become one method call each way, the same in the browser and on the server.
Can I use it in Node.js 24 and TypeScript yet?
Not in Node 24 without a flag. According to MDN’s compatibility data, Node support starts at 25.0.0, Deno at 2.5.0 and Bun at 1.1.22. Node 24 is the Active LTS line and does not have the methods by default. Its V8 does expose --js-base-64, which node --v8-options describes as “in progress / experimental”; that is fine for a spike and not something to run production on. Node 26 is the current release and is scheduled to become LTS on 28 October 2026 per the Node.js release schedule, so as of September 2026 the practical answer for servers is “after your Node 26 upgrade”.
Buffer is a subclass of Uint8Array, so on Node 26 buf.toBase64() works on any Buffer you already have.
For types, TypeScript 6.0 ships the declarations in lib.esnext.typedarrays.d.ts. With "lib": ["esnext"] (or "esnext.typedarrays" added to your existing list) the compiler knows the options and return types, including { read: number; written: number } from setFromBase64(). On TypeScript 5.x you need a small ambient declaration or you keep the old helpers for now.
If you have to support older browsers or Node 24, feature-detect once and fall back:
export function toBase64Url(bytes: Uint8Array): string {
if (typeof bytes.toBase64 === "function") {
return bytes.toBase64({ alphabet: "base64url", omitPadding: true });
}
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
Keep the fallback in one module so deleting it later is a one-file change.
What about large or streamed input?
The TC39 proposal says plainly that there is “no explicit support for streaming”, and leaves it to userland. setFromBase64() is the tool for that. It writes into an existing array and returns how many characters it read and how many bytes it wrote. It never splits a four-character chunk, so if the target is too small you get back a read count that tells you where to resume. Pair it with lastChunkHandling: "stop-before-partial" and you can decode chunk by chunk, carrying the unread tail into the next call. MDN’s setFromBase64 page has a complete Base64Decoder class built this way.
For most code, though, one fromBase64() call on the whole string is fine. Streaming earns its complexity when the base64 is tens of megabytes and arriving over the network.
Should you switch now?
In browser code, yes, if your support matrix already starts at Baseline 2025. Replace the btoa helpers and the hex-mapping loops, and let fromBase64() reject bad input at the edge. On the server, switch as part of the Node 26 upgrade rather than before it, and do not ship on the V8 flag. If you still support Safari before 18.2 or Node 24 in production, keep the feature-detected fallback and put a date on removing it.
Whoooop builds and maintains Node.js and TypeScript services, including the dull upgrade work that makes changes like this safe to roll out. If your stack is due a Node 26 move, our Node.js development team can plan it with you.