ZYVOPMulti-Platform Sync
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZYVOP
The Developer Publishing Hub
SeriesAI NewsPreview My BlogPrivacyTermsGuidelinesDMCACommunity
© 2026 ZyVOP
HomeUint8Array toBase64 and toHex: Stop Round-Tripping Bytes Through btoa

Uint8Array toBase64 and toHex: Stop Round-Tripping Bytes Through btoa

September 25, 2026•
4 min read
Danny Holloran
Danny Holloran
Originally published ondanholloran.me
Senior Developer·
Uint8Array toBase64 and toHex: Stop Round-Tripping Bytes Through btoa
#web-apis#JavaScript#node#performance

Every codebase that touches binary data has the same little helper buried in a utils folder. It takes a Uint8Array, spreads it into String.fromCharCode, hands the result to btoa, and hopes for the best. Its sibling goes the other way with atob and a charCodeAt loop. And somewhere nearby there's a third helper that maps each byte to toString(16).padStart(2, '0') so you can print a hash.

None of those helpers are wrong, exactly. They're just working around the fact that btoa and atob were designed for strings, not bytes. That gap is now closed: Uint8Array has native methods for base64 and hex, in both directions, and they've been Baseline across current Chrome, Firefox, and Safari since September 2025.

The new methods at a glance

There are six of them, split into three pairs:

  • bytes.toBase64(options) and Uint8Array.fromBase64(string, options)
  • bytes.toHex() and Uint8Array.fromHex(string)
  • target.setFromBase64(string, options) and target.setFromHex(string) for writing into an existing buffer

Here's the old helper next to its replacement:

// Before: works, but only by pretending bytes are Latin-1 characters
function bytesToBase64(bytes) {
  return btoa(String.fromCharCode(...bytes));
}

function base64ToBytes(b64) {
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}

// After
const b64 = bytes.toBase64();
const decoded = Uint8Array.fromBase64(b64);

The "after" version isn't just shorter. The spread in String.fromCharCode(...bytes) passes every byte as a separate function argument, so a large file can blow past the engine's argument limit and throw a RangeError. The native methods don't have that ceiling, and they skip the intermediate "binary string" entirely.

Hex gets the same treatment, which makes Web Crypto output a one-liner:

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

await sha256Hex("hello");
// '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'

Uint8Array.fromHex() goes the other direction and throws a SyntaxError on odd-length strings or non-hex characters, so you get validation for free instead of silently producing NaN bytes.

base64url and padding without string surgery

Standard base64 uses + and /, which break in URLs and filenames. The usual fix is a chain of .replace() calls to swap in - and _ and strip the trailing =. Now it's an option:

const id = crypto.getRandomValues(new Uint8Array(16));

const token = id.toBase64({ alphabet: "base64url", omitPadding: true });
// e.g. 'q3N8y0Zp1VbX-7k2_JmR4g'

Decoding takes the same alphabet option. That makes peeking inside a JWT payload pleasantly boring, since JWT segments are base64url with the padding removed:

function readJwtPayload(jwt) {
  const [, payload] = jwt.split(".");
  const bytes = Uint8Array.fromBase64(payload, { alphabet: "base64url" });
  return JSON.parse(new TextDecoder().decode(bytes));
}

That works because the default lastChunkHandling mode, "loose", accepts a final chunk of two or three characters without padding. If you're validating input you don't control and want to reject anything non-canonical, pass lastChunkHandling: 'strict': the last chunk must then be padded to four characters, and any leftover "overflow bits" must be zero. Decoding also ignores ASCII whitespace, so base64 that was wrapped across lines in a PEM file or an email decodes without a cleanup pass.

One more quiet win: because you're decoding to bytes, not to a string, there's no more InvalidCharacterError from feeding btoa a string with an emoji in it. Encode text with TextEncoder first, then call toBase64(), and UTF-8 just works.

Decoding into memory you already have

The setFrom* methods write into an existing Uint8Array and return { read, written } so you know how much input was consumed and how many bytes landed. That's handy when you're reusing a buffer or decoding base64 that arrives in pieces:

const buffer = new Uint8Array(1024);
let offset = 0;
let pending = "";

function onChunk(text) {
  pending += text;
  const { read, written } = buffer
    .subarray(offset)
    .setFromBase64(pending, { lastChunkHandling: "stop-before-partial" });
  offset += written;
  pending = pending.slice(read);
}

With "stop-before-partial", an incomplete trailing chunk is left alone instead of being decoded early or throwing, so the next network chunk can finish it. The read count tells you exactly where to resume.

When to reach for them (and when not yet)

If your app targets current evergreen browsers, you can use these today and delete the helpers. On the server, check your runtime version before you rip out Buffer.from(x, 'base64'). Node's Buffer still works fine, but these methods give you one API that runs the same in the browser, Deno, Bun, and edge runtimes.

For older targets, feature-detect and fall back:

const toBase64 =
  typeof Uint8Array.prototype.toBase64 === "function"
    ? (bytes) => bytes.toBase64()
    : (bytes) =>
        btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(""));

Or pull in a polyfill: core-js covers the full set, and the es-arraybuffer-base64 shim does too.

The takeaway is small but satisfying: bytes can finally stay bytes. The next time you reach for btoa, check whether what you actually have is a Uint8Array. If it is, toBase64() is almost certainly the better call. The MDN reference for Uint8Array.fromBase64() covers every option in detail, including the edge cases around padding and overflow bits.

Comments (0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

Danny Holloran
Danny Holloran

Senior Developer

Senior Frontend & Fullstack Developer with 14+ years building performant, scalable web applications. Passionate about architecture, mentorship, and finding the right tool for the job.

Subscribe to Danny Holloran's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.

Danny Holloran
Like
Love
Clap
Fire
Party
Wow

More from Danny Holloran

View profile

import defer: Lazy Modules Without Going Async

ES2026's import defer evaluates a module the first time you touch its namespace, not at startup, so you can move heavy work off the cold path without turning half your call stack async.

3 minSep 22

Turbopack's Persistent Build Cache Only Helps If Your CI Keeps It

Next.js 16.3 turned on Turbopack's persistent build cache by default and published numbers as high as 5.5x. Most CI pipelines will see exactly zero of that, because the cache is a directory and containers start empty.

4 minSep 21

CSS reading-flow: Fix the Tab Order Your Layout Broke

Grid and flexbox let you rearrange a layout without touching the DOM, which quietly breaks keyboard navigation. The reading-flow and reading-order properties finally let CSS tell the browser which order actually counts.

3 minSep 19

WebNN: The Only Web API That Can Reach Your NPU

Almost every laptop shipped in the last two years has a neural processing unit sitting idle. WebNN is the only web standard that can actually talk to it, and it just hit an updated Candidate Recommendation.

4 minSep 15

GraphQL @oneOf: Exactly One Input, Enforced by the Schema

OneOf Input Objects landed in the September 2025 GraphQL spec, which means the exactly-one-of-these-arguments rule you've been enforcing in resolver code is now something the type system can do for you.

3 minSep 14