· 4 min read

Bytes stop pretending to be text

ECMAScript's Base64 methods put binary conversion on Uint8Array and make callers choose how forgiving a decoder should be.

Coils of yellow five-hole and pink eight-hole punched paper tape sit on a pale surface.
TedColes, Public domain

You have twelve random bytes and need Base64 for a cookie or a URL. In browser JavaScript, the familiar move has been to turn each byte into a character, hand the resulting string to btoa, then perform the whole pantomime backward when the value returns. Read the code aloud and it sounds deranged. Why are bytes dressing up as text so that a binary encoder will speak to them?

The platform finally has an honest answer. ECMAScript 2026 added Base64 conversion directly to Uint8Array, where the binary value already lives, and the new decoder makes its tolerance an explicit option instead of folklore buried in a wrapper.

The binary-string detourjs
const bytes = crypto.getRandomValues(new Uint8Array(12));const encoded = btoa(String.fromCharCode(...bytes));const decoded = Uint8Array.from(  atob(encoded),  character => character.charCodeAt(0),);

That intermediate string is stranger than its tidy one-liner suggests. The current HTML specification says both btoa and atob operate on Unicode strings for historical reasons. Every character passed to btoa must sit between U+0000 and U+00FF, and the algorithm then converts those code points back into eight-bit values. The string only carries numbers through an API that rejects the original array.

The bytes get the method#

The ECMAScript methods make the call site almost suspiciously plain: toBase64 reads a byte array. Uint8Array.fromBase64 returns one. A partial Uint8Array view also keeps its boundary because the encoder reads that view's length, rather than wandering across the entire backing buffer.

Bytes on both sidesjs
const bytes = crypto.getRandomValues(new Uint8Array(12));const encoded = bytes.toBase64({  alphabet: "base64url",  omitPadding: true,});const decoded = Uint8Array.fromBase64(encoded, {  alphabet: "base64url",  lastChunkHandling: "strict",});

Two options carry the protocol choices that Base64's name tends to hide. The alphabet may be ordinary Base64 or the URL-safe variant, and the encoder may omit padding. Those switches come from RFC 4648's separate URL alphabet, which replaces the two punctuation characters that cause trouble in filenames and URLs. You can now see the wire format at the conversion instead of guessing which local function called encodeToken meant which alphabet.

Forgiveness gets a name#

Decoding is where the small API becomes worth arguing about. The decoder's tolerance is one named option, lastChunkHandling, and its strict setting follows RFC 4648's canonical encoding, which warns that nonzero pad bits let several strings represent the same bytes.

loose (default)strictstop-before-partial
Partial final chunka two- or three-character chunk decodes as though padded. A lone character still failsthe final chunk must include its full paddingleft unread until the next piece arrives
Nonzero pad bitstoleratedrejected, since the unused bits must be zero
What the caller keepsthe full result, or an errorthe full result, or an errorevery complete byte, plus read and written counts marking the tail
How each lastChunkHandling mode treats the end of the input.

A JWT-shaped token and a canonical value in a signed document should probably make different choices. I have not measured whether these methods beat a careful library on any engine, and speed is the least interesting promise here anyway. The useful change is visible policy: a reviewer can tell whether the parser requires canonical padding and which alphabet it expects without opening another file.

Decoding one complete prefixjs
const target = new Uint8Array(4096);const result = target.setFromBase64(chunk, {  lastChunkHandling: "stop-before-partial",});chunk = chunk.slice(result.read);output.write(target.subarray(0, result.written));

The third mode, "stop-before-partial", is for input that arrives in pieces. The in-place decoder writes only complete output into an existing array, so a caller never has to pretend that an incomplete quantum is malformed.

At a network-chunk boundary, setFromBase64 may leave the final two characters of chunk unread because they do not complete a four-character group. They sit there waiting for the next piece instead of being condemned as bad input.