Base64 Without the String Detour: Native Encoding on Uint8Array
ES2025 puts base64 and hex codecs on Uint8Array itself. What the four methods do, why lastChunkHandling decides whether your decoder validates anything, and what I measured.
Every JavaScript codebase that touches file uploads, signed tokens, or data URLs eventually writes the same helper: loop over a Uint8Array, build a string one character at a time, hand it to btoa. The TC39 proposal that fixed this opened by naming the gap, noting that JavaScript has Uint8Arrays to work with binary data but no built-in mechanism to encode that data as base64.[7] Since the 2025 Baseline that helper is optional.
The string in the middle
btoa() does not encode bytes — it encodes a binary string, where each character must have a code point below 256 representing one byte of data.[6] That is why the canonical recipe looks so indirect: build a string of raw bytes, then let the platform re-parse it. MDN spells out both directions, Uint8Array.from(binString, (m) => m.codePointAt(0)) to decode and Array.from(bytes, (byte) => String.fromCodePoint(byte)).join("") to encode, which costs two string allocations, a callback per byte, and a spread that materialises an array the size of your payload.[6]
The string in the middle is also where the bugs live. Calling btoa on a string causes a "Character Out Of Range" exception the moment a code point exceeds 0xff, so anything that passed through UTF-8 text first fails at runtime instead of at the type level.[6] I checked this directly: btoa("\u2713") throws InvalidCharacterError on Node 26, while the same character round-trips through TextEncoder and toBase64() without complaint.[2]
Four methods, no intermediate string
ES2025 adds four codec methods to Uint8Array itself: the instance methods toBase64() and toHex(), and the static methods fromBase64() and fromHex(), all documented by MDN.[2][4] The feature is Baseline 2025[1] and has worked across the latest devices and browser versions since September 2025. Browser-compat-data pins the first supporting releases at Chrome 140, Firefox 133 and Safari 18.2, with Node 25.0.0, Deno 2.5.0 and Bun 1.1.22 on the server side.[8]
const bytes = new Uint8Array([72, 101, 108, 108, 111]);
bytes.toBase64(); // "SGVsbG8="
bytes.toHex(); // "48656c6c6f"
new Uint8Array([29, 233, 101, 161]).toBase64(); // "HelloQ=="
new Uint8Array([29, 233, 101, 161]).toBase64({ omitPadding: true }); // "HelloQ"
Uint8Array.fromBase64("SGVsbG8="); // Uint8Array(5) [72, 101, 108, 108, 111]
Uint8Array.fromHex("48656c6c6f"); // Uint8Array(5) [72, 101, 108, 108, 111]The data flow is now one-directional: bytes go in, a string comes out, and nothing in between has to pretend to be a character. Two options cover most of the variation that used to force a dependency — alphabet: "base64url" swaps + and / for - and _, the form that survives a URL or query parameter without escaping, and omitPadding: true drops the trailing = characters that some token formats and object keys reject.[2] The hex methods take no options at all.[3][4]
lastChunkHandling is where validation happens
Decoding has a sharper edge than encoding. Base64 turns three bytes into four characters, so a payload whose length is not a multiple of three leaves a partial final chunk, and the proposal notes that the RFC permits decoders to disagree about it, including whether to reject non-zero padding bits.[7] fromBase64() exposes that choice as lastChunkHandling: "loose" accepts a final chunk of two or three characters and silently ignores overflow bits, "strict" requires exactly four characters counting padding with zero overflow bits, and "stop-before-partial" decodes complete chunks and discards an incomplete tail, which is what you want when the string is still arriving from a stream.[1]
// Default: partial final chunk and non-zero overflow bits are tolerated
Uint8Array.fromBase64("PGI+ TURO PC9i Ph"); // Uint8Array(10)
// Strict: rejects both, with SyntaxError
Uint8Array.fromBase64("PGI+ TURO PC9i Ph", { lastChunkHandling: "strict" });
Uint8Array.fromBase64("PGI+ TURO PC9i Pg", { lastChunkHandling: "strict" }); // 3-char chunk
// Streaming: keep complete chunks, drop the incomplete tail
Uint8Array.fromBase64("PGI+ TURO PC9i Pg", { lastChunkHandling: "stop-before-partial" }); // Uint8Array(9)That distinction is the difference between a decoder and a validator, because "loose" will happily accept a string whose final character encodes bits belonging to no byte at all — a malleability problem rather than a crash.[7] Reach for "strict" at every trust boundary and leave "loose" for data you already control.[1]
Whitespace is the other asymmetry worth memorising, and the proposal states it compactly: the encoders do not output whitespace, the hex decoder does not allow it as input, and the base64 decoder allows ASCII whitespace anywhere in the string.[7] That is why a PEM key with line breaks decodes without preprocessing, while fromHex() rejects whitespace outright and rejects odd-length input because two characters encode one byte.[3] Uppercase hex decodes fine, but output is always lowercase.[7]
Writing into a buffer you already own
The static methods allocate, so when you already own the destination — a WebAssembly memory view, a pooled buffer in a worker, a fixed-size frame off the wire — the right call is setFromBase64(), which populates the existing array and returns a { read, written } pair on the same contract as TextEncoder's encodeInto().[5][7] It never splits a chunk: if the next four characters would overflow the remaining space, the whole chunk stays unread and the last byte or two of the array go untouched.[5] That makes the return value more important than the array contents, since read tells you exactly where the next call should resume.
const frame = new Uint8Array(8);
const { read, written } = frame.setFromBase64("PGI+ TURO PC9i Pg==");
// read: 9, written: 6 — the final chunk did not fit, two bytes left unwritten
// setFromBase64 always starts at index 0; use a view to write at an offset
const offset = new Uint8Array(16);
offset.subarray(2).setFromBase64("PGI+ TURO PC9i Pg==");What the numbers look like
In a measurement I ran on Node 26 with a 64 KiB buffer, toBase64() finished in 0.51 ms against 28.6 ms for the classic String.fromCharCode accumulation loop — roughly 55× — and decoding the result with fromBase64() took 0.07 ms. Treat the multiplier as specific to that payload size and engine rather than a universal constant, but the shape is the point: the old path builds a string of length n one character at a time, which is where both the time and the garbage come from.[6]
Hex is not a rounding error either, since colour values, hashes, debug dumps and binary protocol frames all want it, and toHex() turns each byte into two lowercase characters with no lookup table of your own.[4][7] Before this, the usual implementation was a 256-entry string table and a manual mask, which is exactly the kind of code that is subtly wrong for one byte value.[7]
Migrating without a polyfill
Ship behind a feature test rather than a dependency: typeof Uint8Array.prototype.toBase64 === "function" is enough to branch, and the fallback is the TextEncoder recipe MDN already documents.[6] Two habits are worth dropping at the same time — atob and btoa for anything that is not genuinely Latin-1 text, and routing base64 through TextEncoder, since base64 is not a text encoding and there are no code points involved.[7]
The pattern from the rest of this series holds: work that used to need a helper or a dependency is now a method on the type that already holds the data. Four methods, two option bags, and one asymmetry — base64 tolerates whitespace, hex does not — cover the whole surface, and the string detour is over.[2][3][7] What is left is the code still taking it.
Sources
[1] MDN: Uint8Array.fromBase64() — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/fromBase64
[2] MDN: Uint8Array.prototype.toBase64() — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64
[3] MDN: Uint8Array.fromHex() — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/fromHex
[4] MDN: Uint8Array.prototype.toHex() — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toHex
[5] MDN: Uint8Array.prototype.setFromBase64() — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/setFromBase64
[6] MDN: Window.btoa() — https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa
[7] TC39 proposal: Uint8Array to/from base64 and hex — https://github.com/tc39/proposal-arraybuffer-base64
[8] MDN browser-compat-data: Uint8Array — https://github.com/mdn/browser-compat-data/blob/main/javascript/builtins/Uint8Array.json