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));
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("");
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 newUint8Array. -
bytes.toHex()returns a lowercase hex string. -
Uint8Array.fromHex(string)returns a newUint8Array. -
bytes.setFromBase64(string)andbytes.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 👋"
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"
And hex, in both directions:
console.log(new Uint8Array([202, 254]).toHex()); // "cafe"
console.log(Uint8Array.fromHex("CAFE")); // Uint8Array [202, 254]
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"
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" }
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
Node 24 does not have it. On Node 24.11 (V8 13.6),
typeof Uint8Array.fromBase64is"undefined". If the same code runs on the server, check the Node version before deleting the helper.It lives on
Uint8Arrayonly.ArrayBuffer.prototype.toBase64andUint16Array.prototype.toBase64are bothundefined.crypto.subtle.digest()returns anArrayBuffer, so callingdigest.toHex()throwsTypeError: digest.toHex is not a function. Wrap it innew Uint8Array(...)first.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 throwsSyntaxError, which is exactly the kind of bug that waits for production. Always pass the alphabet you actually have.The default decoder is forgiving.
fromBase64("SGVs\nbG8=")andfromBase64("SGVsbG8")both return the bytes for "Hello": whitespace is ignored and missing padding is fine. If you are validating input rather than reading it, passlastChunkHandling: "strict", and the unpadded version throwsSyntaxError.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 as0. It stops at the last full 3 byte group that fits, so checkreadbefore assuming the whole string went in.Odd hex is an error, not a guess.
Uint8Array.fromHex("abc")throwsSyntaxError: 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
- MDN: Uint8Array.prototype.toBase64()
- MDN: Uint8Array.fromBase64()
- MDN: Uint8Array.prototype.toHex()
- MDN: Uint8Array.prototype.setFromBase64()
- MDN browser-compat-data: Uint8Array
- TC39: Uint8Array to/from Base64 proposal
- TC39: Finished proposals
This article was written with the help of AI and checked against the sources above.
Top comments (0)