Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Stop Hacking Base64 With btoa(). toBase64() Fixes It Natively.

The feature was simple: let someone export a report and share it as a single link, PDF baked right in as a data URL. Worked first try. Worked in the demo. Worked for two weeks in production. Then someone exported a 40-p

The feature was simple: let someone export a report and share it as a single link, PDF baked right in as a data URL. Worked first try. Worked in the demo. Worked for two weeks in production.

Then someone exported a 40-page report, and the share button threw:

RangeError: Maximum call stack size exceeded

Not a network error. Not a permissions error. The encoding step itself — the part of the code nobody had touched since the first version — blew the call stack on a PDF that was, by file standards, perfectly ordinary.

The code that worked right up until it didn't

async function shareReport(url) {
  const res = await fetch(url);
  const bytes = new Uint8Array(await res.arrayBuffer());
  const base64 = btoa(String.fromCharCode(...bytes));
  return `data:application/pdf;base64,${base64}`;
}

This is the pattern you'll find in half the Stack Overflow answers for "convert ArrayBuffer to base64 JavaScript." It reads fine. It passed code review. It's also a time bomb, and the fuse length depends on file size.

Before you keep reading: what do you think ...bytes is actually doing to String.fromCharCode?

It's not passing an array. String.fromCharCode doesn't take one — it takes a list of arguments, one per character code. The spread operator expands every single byte into its own argument in that function call. A 2KB test file is 2,000-ish arguments. Fine. A 40-page PDF is tens of thousands of bytes, which means tens of thousands of arguments in one call — and every JS engine caps how many arguments a function call can take. Cross that line and you don't get a graceful error, you get a stack blow-up with a message that tells you nothing about where it came from.

It gets worse with text, not just files

Swap the binary file for something simpler — base64-encoding a string for a shareable URL — and btoa() has a second failure mode:

btoa("Hello 👋 World");
// DOMException: Failed to execute 'btoa' on 'Window':
// The string to be encoded contains characters outside of
// the Latin1 range.

btoa() was never built to handle arbitrary text. Its actual contract is a "byte string" — every character has to map to a single byte, U+0000 through U+00FF. That range covers plenty of accented Latin letters (café and José actually encode fine, just not as UTF-8), but step outside it — an emoji, an em dash, a name with a character like Ł or a Cyrillic letter in it — and it's an instant throw.

The classic fix you'll find for this one is uglier than the bug:

btoa(unescape(encodeURIComponent(text)));

It works, in the sense that it doesn't throw. It also routes your string through unescape() — a function the spec deprecated decades ago and keeps around purely so this exact hack doesn't break the web. If you've ever wondered why base64-encoding a string in JavaScript feels like three functions wearing a trenchcoat, this is why: btoa() does one narrow job, and everything else is a workaround bolted on from outside.

Why both bugs have the same root cause

btoa() and atob() operate on strings, not bytes. Any time your actual data is binary — a fetched file, a crypto digest, Unicode text — you have to convert it into a byte-string-shaped JavaScript string first. That conversion step is where both bugs live: String.fromCharCode(...bytes) is how you build that string for binary data, and it's the one that blows the call stack. encodeURIComponent plus unescape is how you build it for Unicode text, and it's the one that's a deprecated-function sandwich.

The actual problem was never base64. It was the round trip through a string that base64 never needed in the first place.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The fix: encode the bytes directly

As of late 2025, Uint8Array has two methods that skip the string entirely:

async function shareReport(url) {
  const res = await fetch(url);
  const bytes = new Uint8Array(await res.arrayBuffer());
  const base64 = bytes.toBase64(); // no spread, no stack limit
  return `data:application/pdf;base64,${base64}`;
}

One call. No String.fromCharCode, no spread, no argument-count ceiling, because nothing is being passed as individual arguments — toBase64() walks the bytes internally and hands back a string. Decoding mirrors it exactly:

const bytes = Uint8Array.fromBase64(base64);

For the text case, pair it with TextEncoder/TextDecoder instead of the unescape hack:

const base64 = new TextEncoder().encode("Hello 👋 World").toBase64();
const text = new TextDecoder().decode(Uint8Array.fromBase64(base64));
// text === "Hello 👋 World"

TextEncoder turns the string into UTF-8 bytes correctly — emoji, accents, anything — and toBase64() takes it from there. No deprecated functions, no silent mis-encoding.

The option you'll actually reach for: URL-safe tokens

Standard base64 uses + and /, both of which need escaping in a URL or a filename. toBase64() takes an alphabet option so you don't hand-roll the replacement:

const token = crypto.getRandomValues(new Uint8Array(16))
  .toBase64({ alphabet: "base64url", omitPadding: true });
// "Kx8pQ2v9mZt3dF7h..."  — safe to drop straight into a URL, no `+`, `/`, or `=`

omitPadding: true strips the trailing = characters too — the part that trips up anyone who's ever copy-pasted a base64 token into a query string and watched it get mangled.

The part worth being honest about

Uint8Array.prototype.toBase64() and Uint8Array.fromBase64() reached Baseline "newly available" in September 2025 — Chrome 140, Firefox 133, Safari 18.2, and the versions after. That's all three engines, not a single-vendor experiment, which is why it's worth adopting now rather than waiting. It is not in anything older than that, though, so if your app still supports last year's browsers, feature-detect before you rely on it:

const hasNativeBase64 = typeof Uint8Array.prototype.toBase64 === "function";

Keep your btoa/atob path behind that check for a while, rather than ripping it out the day you read this.

The report export, fixed

The 40-page PDF that used to crash the share button now encodes in one line, with no upper bound tied to how many arguments a function call can hold. The emoji that used to throw a DOMException round-trips cleanly through TextEncoder. Neither fix required a library — both were already shipping in the runtime, waiting for the hand-rolled version to get retired.

If you've still got a String.fromCharCode(...bytes) or an unescape(encodeURIComponent(...)) somewhere in your codebase, it's probably not broken yet. It's just waiting for a big enough file or a weird enough character. What's the oldest base64 hack still running in your production code — do you know what size file would finally break it?

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.