DEV Community

JKC
JKC

Posted on

Stop Spreading Bytes Into btoa(). Uint8Array Speaks Base64 Now

TL;DR: bytes.toBase64() and Uint8Array.fromBase64(str) turn bytes into base64 and back, and toHex() / fromHex() do the same for hex. They handle URL-safe base64, padding, and whitespace, and they are Baseline 2025 Newly available.

The one-liner that is secretly three bugs

You have bytes. You need base64. So you reach for the line every Stack Overflow answer hands you:

const base64 = btoa(String.fromCharCode(...bytes));
Enter fullscreen mode Exit fullscreen mode

It works in the demo. Then it meets real data.

Feed btoa() a string with an emoji and Chrome throws InvalidCharacterError, because btoa() only understands Latin1. Feed the spread a 1 MB Uint8Array and Chrome 156 throws RangeError: Maximum call stack size exceeded, because every byte became a function argument. And when the token is URL-safe base64, you get the bonus round of .replace(/-/g, "+").replace(/_/g, "/") plus padding math.

Hex is no better:

const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
Enter fullscreen mode Exit fullscreen mode

Fine. It also looks like a sentence written by a compiler.

What it is

The TC39 "Uint8Array to/from Base64" proposal reached Stage 4 in July 2025, and TC39 lists ECMAScript 2026 as its expected publication year. It adds four methods you will use and two you will be glad exist:

  • bytes.toBase64(options) returns a base64 string.
  • Uint8Array.fromBase64(string, options) returns a new Uint8Array.
  • bytes.toHex() returns a lowercase hex string.
  • Uint8Array.fromHex(string) returns a new Uint8Array.
  • bytes.setFromBase64(string) and bytes.setFromHex(string) decode into an array you already have.

The options are small:

  • alphabet: "base64" (default, uses + and /) or "base64url" (uses - and _).
  • omitPadding (encode only): drop the trailing =.
  • lastChunkHandling (decode only): "loose" (default), "strict", or "stop-before-partial".

A minimal example

Text to base64 and back, emoji included:

const bytes = new TextEncoder().encode("héllo 👋");

const encoded = bytes.toBase64();
console.log(encoded); // "aMOpbGxvIPCfkYs="

const decoded = new TextDecoder().decode(Uint8Array.fromBase64(encoded));
console.log(decoded); // "héllo 👋"
Enter fullscreen mode Exit fullscreen mode

The same 1 MB array that blew the stack above encodes to a 1,333,336 character string without complaint.

URL-safe output and padding are just options:

const b = new Uint8Array([251, 255, 191]);
console.log(b.toBase64());                         // "+/+/"
console.log(b.toBase64({ alphabet: "base64url" })); // "-_-_"

const two = new Uint8Array([1, 2]);
console.log(two.toBase64());                       // "AQI="
console.log(two.toBase64({ omitPadding: true }));  // "AQI"
Enter fullscreen mode Exit fullscreen mode

And hex, in both directions:

console.log(new Uint8Array([202, 254]).toHex()); // "cafe"
console.log(Uint8Array.fromHex("CAFE"));         // Uint8Array [202, 254]
Enter fullscreen mode Exit fullscreen mode

Uppercase input is accepted, and output is always lowercase.

A realistic example: hashes and tokens

Hashing a string with Web Crypto used to end in that padStart line. Now it ends in one call:

async function sha256Hex(text) {
  const data = new TextEncoder().encode(text);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return new Uint8Array(digest).toHex();
}

console.log(await sha256Hex("hello"));
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
Enter fullscreen mode Exit fullscreen mode

Reading the payload of a JWT, which is unpadded base64url, is just as short:

function decodeJwtPayload(token) {
  const part = token.split(".")[1];
  const bytes = Uint8Array.fromBase64(part, { alphabet: "base64url" });
  return JSON.parse(new TextDecoder().decode(bytes));
}

// payload part: "eyJzdWIiOiI0MiIsIm5hbWUiOiJBbmEifQ"
// result: { sub: "42", name: "Ana" }
Enter fullscreen mode Exit fullscreen mode

No character swapping, no padding math. Decoding is not verifying, so do not trust that payload until the signature checks out.

Every output in this post is from a run in Chrome 156.

Can I use it?

From MDN's compatibility data:

Runtime Version
Chrome / Edge 140
Firefox 133
Safari 18.2
Node.js 25.0.0

MDN marks the methods Baseline 2025, Newly available since September 2025. Current browsers are fine; older ones need a fallback, because the methods are simply missing there.

Gotchas

  1. Node 24 does not have it. On Node 24.11 (V8 13.6), typeof Uint8Array.fromBase64 is "undefined". If the same code runs on the server, check the Node version before deleting the helper.

  2. It lives on Uint8Array only. ArrayBuffer.prototype.toBase64 and Uint16Array.prototype.toBase64 are both undefined. crypto.subtle.digest() returns an ArrayBuffer, so calling digest.toHex() throws TypeError: digest.toHex is not a function. Wrap it in new Uint8Array(...) first.

  3. Forgetting alphabet: "base64url" works until it does not. The JWT payload above decodes fine with the default alphabet, because it happens to contain no - or _. The first token that does throws SyntaxError, which is exactly the kind of bug that waits for production. Always pass the alphabet you actually have.

  4. The default decoder is forgiving. fromBase64("SGVs\nbG8=") and fromBase64("SGVsbG8") both return the bytes for "Hello": whitespace is ignored and missing padding is fine. If you are validating input rather than reading it, pass lastChunkHandling: "strict", and the unpadded version throws SyntaxError.

  5. setFromBase64() only writes whole chunks. Decoding "Hello world" (11 bytes) into an 8 byte array returned { read: 8, written: 6 } and left the last two bytes as 0. It stops at the last full 3 byte group that fits, so check read before assuming the whole string went in.

  6. Odd hex is an error, not a guess. Uint8Array.fromHex("abc") throws SyntaxError: Input string must contain hex characters in even length. Good. Silently padding would have been worse.

Wrap-up

btoa(String.fromCharCode(...bytes)) was always a string API wearing a bytes costume. Search your codebase for fromCharCode(... and padStart(2, "0"), and let Uint8Array do the job it was clearly meant for.

Sources

This article was written with the help of AI and checked against the sources above.

Top comments (0)