[JS] 10 min read

[JS] Base64 in JavaScript — atob, btoa, and Node Buffer

A practical guide to Base64 in JavaScript. When to use btoa/atob, why they break on Unicode, how Buffer works in Node.js, and a cross-runtime base64url helper.

April 2026 | javascript

// THE LANDSCAPE

JavaScript's story with Base64 is fragmented by history. The browser inherited btoa() / atob() from Netscape in the 1990s — functions whose names literally stand for binary-to-ASCII and ASCII-to-binary. They only handle Latin-1 characters, which is why copy-pasting emoji or Chinese text through btoa() throws an error.

Node.js added its own path through the Buffer class. It understands every encoding you are likely to need (utf8, base64, base64url, hex, latin1, ascii), handles binary data natively, and is the obvious choice for server-side code.

Modern runtimes (Node 18+, Deno, Bun, modern browsers) also ship TextEncoder / TextDecoder — the standards-based way to turn Unicode strings into bytes before Base64-encoding them. Knowing which tool to reach for is half the battle.

// BROWSER: btoa / atob — THE CORRECT USE

btoa(str) takes a string where every character must fit in 1 byte (Latin-1 range, U+0000 to U+00FF) and returns a Base64 string. atob(b64) reverses it. They are synchronous, fast, and built into every browser since forever.

The correct use is: Base64 already-byte-ish data. Raw bytes read from a FileReader, from a binary XMLHttpRequest (responseType: 'arraybuffer'), or from a Uint8Array, where each byte's numeric value fits in 0–255.

// Encode a Latin-1 string
const latin1 = 'Hello, world!';
const encoded = btoa(latin1);   // 'SGVsbG8sIHdvcmxkIQ=='

// Decode it back
const decoded = atob(encoded);  // 'Hello, world!'

// Encode a binary Uint8Array (the common real-world case)
const bytes = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0]);  // JPEG magic
const binStr = String.fromCharCode(...bytes);            // 'ÿØÿà' (Latin-1)
const b64 = btoa(binStr);                                // '/9j/4A=='

// WHY btoa() THROWS ON UNICODE

Try btoa('héllo') in a browser console. You get InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range. The é (U+00E9) is fine, but btoa('日本語') fails because (U+65E5) is outside Latin-1. The function has no concept of UTF-8; it reads each JavaScript String code unit as a single byte.

The fix is to turn the Unicode string into UTF-8 bytes first, then Base64 those bytes. Every modern browser ships TextEncoder for exactly this.

// ❌ broken
// btoa('日本語')  → throws InvalidCharacterError

// ✅ correct: encode to UTF-8 first
function b64encodeUtf8(str) {
  const bytes = new TextEncoder().encode(str);
  let bin = '';
  for (const byte of bytes) bin += String.fromCharCode(byte);
  return btoa(bin);
}

b64encodeUtf8('日本語');   // '5pel5pys6Kqe'

// ✅ round-trip decode
function b64decodeUtf8(b64) {
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

b64decodeUtf8('5pel5pys6Kqe');  // '日本語'

// NODE.JS: THE Buffer APPROACH

In Node, reach for Buffer. It is UTF-8 aware by default, supports every encoding under the sun, and handles binary cleanly. Node 16+ also understands 'base64url' as an encoding name, so you do not need a separate helper for URL-safe output.

// String → Base64
const encoded = Buffer.from('héllo 世界').toString('base64');
// 'aMOpbGxvIOS4lueVjA=='

// Base64 → string (UTF-8 by default)
const decoded = Buffer.from(encoded, 'base64').toString();
// 'héllo 世界'

// URL-safe (Node 16+): +/- omitted padding
const safe = Buffer.from('héllo 世界').toString('base64url');
// 'aMOpbGxvIOS4lueVjA'  ← no = padding, uses -_

// Binary file → Base64
const fs = require('node:fs');
const imgB64 = fs.readFileSync('logo.png').toString('base64');

// Base64 → binary file
fs.writeFileSync('out.png', Buffer.from(imgB64, 'base64'));

// BUFFER VS btoa/atob — QUICK COMPARISON

  • > Unicode: Buffer handles UTF-8 natively; btoa/atob only handle Latin-1 bytes.
  • > Binary: Buffer is the native binary type in Node; browsers must use Uint8Array + String.fromCharCode.
  • > URL-safe: Buffer supports 'base64url' as a first-class encoding (Node 16+); browsers need a custom regex replacement.
  • > Performance: Buffer is implemented in C++ and is ~3–5× faster than atob/btoa on large inputs.
  • > Streaming: Buffer can be piped through transform streams for large files; btoa/atob cannot stream.
  • > Availability: btoa/atob exist in Node 16+ as globals, but reach for Buffer when you have the choice — it handles the edge cases.

// ENCODING AND DECODING BINARY FILES IN THE BROWSER

The canonical browser pattern for turning a file picker selection into a Base64 string is FileReader.readAsDataURL(), which returns a ready-to-use data URI. If you only want the raw Base64 payload, strip the data:...;base64, prefix.

For very large files, read with readAsArrayBuffer() and encode in chunks to avoid the 'maximum call stack size exceeded' error that String.fromCharCode(...bytes) hits on arrays longer than ~65,000 elements.

// ▸ Simple: file → data URI → strip prefix
async function fileToBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      const dataUri = reader.result;                      // 'data:image/png;base64,iVBOR...'
      resolve(dataUri.split(',')[1]);                     // raw Base64 only
    };
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

// ▸ Large files: chunked btoa() to avoid stack overflow
async function arrayBufferToBase64(buffer) {
  const bytes = new Uint8Array(buffer);
  const chunkSize = 0x8000;      // 32 KB chunks
  let binary = '';
  for (let i = 0; i < bytes.length; i += chunkSize) {
    binary += String.fromCharCode.apply(null,
      bytes.subarray(i, i + chunkSize));
  }
  return btoa(binary);
}

// Usage
const file = inputEl.files[0];
const buf = await file.arrayBuffer();
const b64 = await arrayBufferToBase64(buf);

// DECODING BASE64 TO BYTES IN THE BROWSER

// ▸ Base64 → Uint8Array (binary bytes)
function base64ToBytes(b64) {
  const bin = atob(b64);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return bytes;
}

// ▸ Base64 → Blob → downloadable
function base64ToBlob(b64, mime = 'application/octet-stream') {
  const bytes = base64ToBytes(b64);
  return new Blob([bytes], { type: mime });
}

// Trigger a download
const blob = base64ToBlob(b64, 'image/png');
const url = URL.createObjectURL(blob);
Object.assign(document.createElement('a'), { href: url, download: 'out.png' }).click();
URL.revokeObjectURL(url);

// URL-SAFE BASE64 (base64url) IN JAVASCRIPT

Standard Base64 uses + and /, both of which are reserved characters in URLs. It also ends with one or two = characters that get percent-encoded to %3D, wasting space. RFC 4648 §5 defines the URL-safe variant (base64url): swap + for -, / for _, and drop the padding.

In Node 16+, just use Buffer.from(x).toString('base64url'). In the browser, implement it as a post-processing step on top of btoa().

// ▸ Browser: standard Base64 → URL-safe Base64
function toBase64Url(b64) {
  return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// ▸ URL-safe Base64 → standard Base64 (before feeding atob)
function fromBase64Url(b64u) {
  let b64 = b64u.replace(/-/g, '+').replace(/_/g, '/');
  while (b64.length % 4) b64 += '=';   // re-add padding
  return b64;
}

// Round-trip
const token = toBase64Url(btoa('hello+world/'));
// 'aGVsbG8rd29ybGQv'
atob(fromBase64Url(token));  // 'hello+world/'

// COMMON PITFALLS & HOW TO CATCH THEM

  • > InvalidCharacterError from btoa() — your input contains characters outside Latin-1. Fix: encode to UTF-8 with TextEncoder first.
  • > InvalidCharacterError from atob() — your input is not valid Base64. Usually missing padding (for base64url), or has whitespace. Strip whitespace, re-add = padding.
  • > Garbled Unicode on decode — you decoded with atob() directly instead of going through TextDecoder. UTF-8 multi-byte sequences come through as mojibake.
  • > 'Maximum call stack size exceeded' — you used String.fromCharCode(...bytes) on an array of more than ~65,000 bytes. Fix: chunk it (see the 32 KB loop above).
  • > Different output between browser and Node — usually means one side is URL-safe and the other is standard. Normalise to one variant at the boundary.
  • > Double-encoded payload — the data was Base64'd twice. Check: does atob(input) look like Base64 again? Then decode once more.
  • > Base64 in JSON accidentally JSON-encoded — you wrapped a Base64 string in JSON.stringify, then the receiver parses JSON and gets the string unchanged, but your server code re-encoded it by mistake. Inspect the wire payload.

// CROSS-RUNTIME HELPER (browser + Node + Deno + Bun)

// A portable Base64/base64url helper. Uses Buffer when available,
// falls back to btoa/atob + TextEncoder in the browser / Deno.

const hasBuffer = typeof Buffer !== 'undefined';

export function encode(str, { urlSafe = false } = {}) {
  if (hasBuffer) {
    return Buffer.from(str, 'utf8').toString(urlSafe ? 'base64url' : 'base64');
  }
  const bytes = new TextEncoder().encode(str);
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  const b64 = btoa(bin);
  return urlSafe
    ? b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
    : b64;
}

export function decode(b64, { urlSafe = false } = {}) {
  if (hasBuffer) {
    return Buffer.from(b64, urlSafe ? 'base64url' : 'base64').toString('utf8');
  }
  if (urlSafe) {
    b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
    while (b64.length % 4) b64 += '=';
  }
  const bin = atob(b64);
  const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

// Usage
encode('héllo 世界');                      // 'aMOpbGxvIOS4lueVjA=='
encode('héllo 世界', { urlSafe: true });    // 'aMOpbGxvIOS4lueVjA'
decode('aMOpbGxvIOS4lueVjA==');            // 'héllo 世界'

// PERFORMANCE NOTES

  • > Buffer is fastest — in Node, it runs in C++ and handles megabyte-sized inputs without hiccups.
  • > btoa/atob are competitive for small payloads (< 100 KB) in the browser; both are synchronous and will block the main thread on large inputs.
  • > For > 10 MB inputs in the browser, use a Worker so the encoding runs off the main thread — or use Blob.text() + FileReader.readAsDataURL() which is optimised internally.
  • > Avoid repeated encode/decode cycles — each round-trip is O(n) in both time and memory. Cache the Base64 form if you use it multiple times.
  • > Streaming — for truly large payloads (100 MB+), use Node's stream.Transform or the browser's TransformStream API with a chunked Base64 encoder.

// RELATED READS

What is Base64 and how does it work? — the 6-bit grouping trick explained from scratch.
Base64 for UTF-8 and Unicode — the TextEncoder dance in depth.
URL-safe vs standard Base64 — when you need -_ instead of +/.
Try the live Base64 encoder — paste text, see the encoding, copy the result.

Bookmark this page if you ever reach for btoa() — nine times out of ten, the question you are about to Google is here.