Base64 Encoding in JavaScript: btoa, atob, and Beyond

JavaScript provides built-in functions for Base64 encoding and decoding, but they come with important caveats — particularly around Unicode handling. This guide covers the browser APIs, the Node.js approach, techniques for handling binary data and UTF-8 strings, URL-safe encoding, and common pitfalls that catch developers off guard.

The Built-in Functions: btoa() and atob()

Every modern browser provides two global functions for Base64:

  • btoa(string) — Encodes a string to Base64 ("binary to ASCII")
  • atob(base64) — Decodes a Base64 string back to a binary string ("ASCII to binary")
const encoded = btoa('Hello World');
console.log(encoded);  // "SGVsbG8gV29ybGQ="

const decoded = atob('SGVsbG8gV29ybGQ=');
console.log(decoded);  // "Hello World"

For plain ASCII strings, these functions work perfectly. The trouble starts when you move beyond ASCII.

The Unicode Problem

btoa() only accepts strings where every character has a code point in the range 0-255 (the Latin-1 / ISO-8859-1 range). If you pass a string containing characters outside this range — which includes most non-English text, emoji, and many symbols — it throws an error:

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

This is a fundamental limitation. Despite the name "binary to ASCII," btoa()does not actually handle arbitrary binary data or Unicode text. You need a workaround.

The TextEncoder/TextDecoder Approach for UTF-8

The modern solution for encoding Unicode strings as Base64 in the browser is to use TextEncoder to first convert the string into UTF-8 bytes, then encode those bytes as Base64. For decoding, you reverse the process with TextDecoder.

// Encode a Unicode string to Base64
function encodeBase64(str) {
  const encoder = new TextEncoder();
  const bytes = encoder.encode(str);
  const binString = Array.from(bytes, (byte) =>
    String.fromCodePoint(byte)
  ).join('');
  return btoa(binString);
}

// Decode Base64 back to a Unicode string
function decodeBase64(base64) {
  const binString = atob(base64);
  const bytes = Uint8Array.from(binString, (char) =>
    char.codePointAt(0)
  );
  const decoder = new TextDecoder();
  return decoder.decode(bytes);
}

// Now Unicode works correctly
const encoded = encodeBase64('Hello 🌍');
console.log(encoded);            // "SGVsbG8g8J+MjQ=="
console.log(decodeBase64(encoded)); // "Hello 🌍"

This two-step approach — string to UTF-8 bytes, then bytes to Base64 — is the reliable pattern for all Unicode text. TextEncoder always produces UTF-8, which is the encoding you almost always want.

Node.js: Buffer.from()

Node.js does not have btoa() and atob() in older versions (they were added in Node 16). The idiomatic Node.js approach uses Buffer:

// Encode string to Base64
const encoded = Buffer.from('Hello World', 'utf-8').toString('base64');
console.log(encoded);  // "SGVsbG8gV29ybGQ="

// Decode Base64 to string
const decoded = Buffer.from('SGVsbG8gV29ybGQ=', 'base64').toString('utf-8');
console.log(decoded);  // "Hello World"

// Unicode works natively with Buffer
const emoji = Buffer.from('Hello 🌍', 'utf-8').toString('base64');
console.log(emoji);  // "SGVsbG8g8J+MjQ=="

// URL-safe Base64 (Node 16+)
const urlSafe = Buffer.from('Hello World').toString('base64url');
console.log(urlSafe);  // "SGVsbG8gV29ybGQ"

Buffer.from() handles UTF-8 natively, so you do not need the TextEncoder workaround. It also supports the base64url encoding directly (added in Node 16), which produces URL-safe Base64 without padding.

Handling Binary Data: Uint8Array to Base64

When you are working with actual binary data in the browser — such as file contents, image data, or cryptographic output — you are typically dealing with ArrayBuffer or Uint8Array. Converting these to Base64 requires going through a binary string:

// Uint8Array → Base64
function uint8ArrayToBase64(bytes) {
  const binString = Array.from(bytes, (byte) =>
    String.fromCodePoint(byte)
  ).join('');
  return btoa(binString);
}

// Base64 → Uint8Array
function base64ToUint8Array(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (char) =>
    char.codePointAt(0)
  );
}

// Example: encode binary data
const bytes = new Uint8Array([72, 101, 108, 108, 111]);
console.log(uint8ArrayToBase64(bytes));  // "SGVsbG8="

URL-Safe Base64 in JavaScript

The browser does not provide a built-in URL-safe Base64 function. You need to perform the character substitutions yourself after encoding, or before decoding:

// Encode to URL-safe Base64 (no padding)
function toBase64Url(str) {
  return btoa(str)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

// Decode from URL-safe Base64
function fromBase64Url(base64url) {
  // Replace URL-safe chars with standard chars
  let base64 = base64url
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  // Re-add padding
  while (base64.length % 4 !== 0) {
    base64 += '=';
  }
  return atob(base64);
}

console.log(toBase64Url('subjects?')); // "c3ViamVjdHM_"
console.log(fromBase64Url('c3ViamVjdHM_')); // "subjects?"

For the full UTF-8-safe version, combine this with the TextEncoder approach shown earlier: first encode the string to UTF-8 bytes, convert to a binary string, apply btoa(), then do the URL-safe replacements.

Practical Examples

Encoding File Uploads as Base64

A common use case is reading a file selected by the user and converting it to a Base64 string for upload via a JSON API:

function fileToBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      // reader.result is a data URI like "data:image/png;base64,iVBOR..."
      // Extract just the Base64 portion
      const base64 = reader.result.split(',')[1];
      resolve(base64);
    };
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

// Usage with a file input
const input = document.querySelector('input[type="file"]');
input.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const base64 = await fileToBase64(file);
  // Send to API
  fetch('/api/upload', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ filename: file.name, data: base64 }),
  });
});

Creating and Reading Data URIs

Data URIs embed file contents directly in URLs. You can create them from Base64 strings and also extract the Base64 data from existing data URIs:

// Create a data URI from a string
const text = 'Hello, World!';
const base64 = btoa(text);
const dataUri = `data:text/plain;base64,${base64}`;
// "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="

// Extract Base64 from a data URI
function extractBase64(dataUri) {
  const [header, data] = dataUri.split(',');
  const isBase64 = header.includes(';base64');
  return isBase64 ? atob(data) : decodeURIComponent(data);
}

Decoding JWT Payloads

JWTs use URL-safe Base64 (without padding) for their header and payload. You can decode the payload to inspect claims without verifying the signature:

function decodeJwtPayload(token) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('Invalid JWT');

  // Decode the payload (second segment)
  let payload = parts[1]
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  while (payload.length % 4 !== 0) {
    payload += '=';
  }

  const json = atob(payload);
  return JSON.parse(json);
}

const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4ifQ.xxx';
console.log(decodeJwtPayload(jwt));
// { sub: "1234567890", name: "John" }

Common Pitfalls

1. Assuming btoa() Handles Unicode

As discussed above, btoa() throws on any character above code point 255. Always use the TextEncoder approach for non-ASCII text.

2. Forgetting to Restore Padding

URL-safe Base64 strings often have padding stripped. If you pass an unpadded string to atob(), it may fail or produce incorrect output in some environments. Always re-add = characters to make the length a multiple of 4 before decoding.

3. Encoding Strings vs. Bytes

A common confusion: btoa('hello') and Buffer.from('hello').toString('base64') produce the same result for ASCII text, but they operate on different abstractions. btoa() works on a "binary string" (each character is a byte), while Buffer works on actual bytes. This distinction matters when interoperating between browser and server code.

4. Double Encoding

Watch out for accidentally Base64-encoding data that is already Base64-encoded. This produces a valid but meaningless string that is twice the expected size. If your decoded output still looks like Base64, you have probably double-encoded somewhere.

5. Performance with Large Files

Base64 encoding a large file (several MB) in the browser using the string concatenation methods above can be slow and memory-intensive. For large files, consider using FileReader.readAsDataURL() which handles the encoding natively, or process the data in chunks using streaming APIs.

Related Guides