How to Encode and Decode Base64 in JavaScript (Browser + Node)

JavaScript's relationship with Base64 encoding is more complicated than it needs to be. The browser has btoa() and atob(). Node.js has those too (since v16) plus Buffer. Neither environment handles the full range of data types you'll throw at it without a wrapper.

After debugging enough "invalid Base64" errors from frontend code that looked correct, I've collected the patterns that actually work in production — for the browser, for Node.js, and for code that runs in both.

Browser: btoa() and atob()

Every modern browser exposes btoa() (binary to ASCII) and atob() (ASCII to binary) as global functions:

// Encode a string
const encoded = btoa("hello world");
// "aGVsbG8gd29ybGQ="

// Decode a Base64 string
const decoded = atob("aGVsbG8gd29ybGQ=");
// "hello world"

These have been available since Internet Explorer 10 and are supported in every major browser. They're also available in Node.js since version 16.

The Latin1 Limitation

The single biggest footgun with btoa(): it only accepts characters in the Latin1 range (U+0000 to U+00FF). Any character outside that range throws InvalidCharacterError:

btoa("café");   // InvalidCharacterError — é is U+00E9, within Latin1
btoa("你好");    // InvalidCharacterError — 你 is U+4F60, outside Latin1
btoa("😀");      // InvalidCharacterError — 😀 is U+1F600, outside Latin1

Wait — é is within Latin1 (0xE9), but some variants of é (like combining sequences) can still fail. The safe rule: if the string contains any character above U+00FF, btoa() throws.

The UTF-8 Workaround

To encode arbitrary Unicode text, convert to UTF-8 bytes first:

function base64Encode(str) {
  const bytes = new TextEncoder().encode(str);
  let binary = "";
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}

function base64Decode(b64) {
  const binary = atob(b64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return new TextDecoder().decode(bytes);
}

console.log(base64Encode("你好世界")); // "5L2g5aW95LiW55WM"
console.log(base64Decode("5L2g5aW95LiW55WM")); // "你好世界"

This pattern is reliable, well-documented, and handles every Unicode character correctly.

Browser: Encoding Binary Data (FileReader + ArrayBuffer)

When you're working with actual binary files — images uploaded by the user, downloaded PDFs, audio recordings — you need FileReader:

function fileToBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

// Usage
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener("change", async (event) => {
  const file = event.target.files[0];
  const dataUrl = await fileToBase64(file);
  // dataUrl is "data:image/png;base64,iVBORw0KGgo..."
  console.log(dataUrl.substring(0, 50));
});

readAsDataURL returns a complete data URI including the MIME type prefix. If you need just the raw Base64 string (without the data:...;base64, prefix), strip it:

function fileToBase64Raw(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      const result = reader.result; // "data:image/png;base64,iVBOR..."
      const base64 = result.split(",")[1];
      resolve(base64);
    };
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

See the full guide on converting images to Base64 with the FileReader API for more detail.

Node.js: Buffer API

In Node.js, Buffer is the standard:

// Encode
const encoded = Buffer.from("hello world").toString("base64");
// "aGVsbG8gd29ybGQ="

// Decode
const decoded = Buffer.from("aGVsbG8gd29ybGQ=", "base64").toString("utf8");
// "hello world"

// Encode binary files
const fs = require("fs");
const imageBase64 = fs.readFileSync("photo.png").toString("base64");

// Decode back to binary
fs.writeFileSync("photo-copy.png", Buffer.from(imageBase64, "base64"));

Buffer handles binary data, Unicode, and large payloads without the Latin1 limitation.

Node.js: atob()/btoa() (Available Since v16)

Node.js added global atob() and btoa() functions in v16. They match the browser API exactly, including the Latin1 limitation:

// These work:
atob(btoa("hello world")); // "hello world"

// These throw:
btoa("你好"); // InvalidCharacterError

Use them for quick scripts or REPL work. Use Buffer for anything that touches production data.

Isomorphic Code (Browser + Node)

If you need one code path that works in both environments, detect the runtime:

function isNode() {
  return typeof Buffer !== "undefined" && typeof process !== "undefined";
}

function toBase64(str) {
  if (isNode()) {
    return Buffer.from(str).toString("base64");
  }
  // Browser: convert through UTF-8 bytes
  const bytes = new TextEncoder().encode(str);
  let binary = "";
  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary);
}

function fromBase64(b64) {
  if (isNode()) {
    return Buffer.from(b64, "base64").toString("utf8");
  }
  const binary = atob(b64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return new TextDecoder().decode(bytes);
}

Some isomorphic libraries (like axios or next-auth) handle this internally. If you're rolling your own, the pattern above covers both environments without dependencies.

Common Mistakes

Mistake 1: Forgetting to Strip the Data URI Prefix

const base64 = reader.result; // "data:image/png;base64,iVBOR..."
atob(base64); // Fails — "data:image/png;base64," is not valid Base64

Always strip the prefix before decoding:

const raw = reader.result.split(",")[1];
atob(raw); // Works

Mistake 2: Using btoa() on Binary Data

const response = await fetch("/api/file");
const blob = await response.blob();

// Wrong — Blob is not a string
const encoded = btoa(blob); // [Object Blob] encoded as literal text

// Convert blob to ArrayBuffer first
const arrayBuffer = await blob.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
let binary = "";
for (const byte of bytes) {
  binary += String.fromCharCode(byte);
}
const encoded = btoa(binary);

Mistake 3: Ignoring Newlines in PEM/Key Files

// CERT_BASE64 contains a PEM with embedded \n
const decoded = Buffer.from(process.env.CERT_BASE64, "base64");
const cert = decoded.toString("utf8"); // Contains literal "\n" strings

// Strip actual newlines first
const clean = process.env.CERT_BASE64.replace(/\n/g, "").replace(/\r/g, "");
const decoded = Buffer.from(clean, "base64");

FAQ

What's the difference between btoa() and atob()?

btoa() encodes a string to Base64 (binary to ASCII). atob() decodes Base64 back to a string (ASCII to binary). Both are browser-native functions also available in Node.js v16+.

Why does btoa() throw InvalidCharacterError?

The input contains a character outside the Latin1 range (U+0000 to U+00FF). Use TextEncoder to convert to UTF-8 bytes first, then apply btoa().

How do I Base64-encode a file in the browser?

Use FileReader.readAsDataURL(). It returns a data URI string containing the Base64-encoded file with a MIME prefix. Split on , to get the raw Base64 portion.

Can I decode Base64 in Node.js without Buffer?

Yes, use atob() (available since Node.js v16). But Buffer handles binary data and Unicode strings without workarounds, so it's the safer default.

What's the fastest way to Base64-encode large data in JavaScript?

For the browser, TextEncoder + btoa() is the fastest pure-JS approach. For Node.js, Buffer.toString("base64") is the fastest and most reliable.


If you frequently work with Base64-encoded data in your JavaScript projects, the Base64 Encoder & Decoder tool handles both browser and Node.js formats. It's useful for quickly inspecting API responses, decoding JWT segments, and validating Base64 strings during development. For a deeper dive into why Base64 isn't a security mechanism, see Base64 Is Not Encryption.