Base64 encoding converts binary data (images, files, arbitrary bytes) into a string of ASCII characters. It's everywhere in web development: data URIs, JSON payloads, email attachments, JWT tokens, HTTP Basic auth. The encoding itself is simple — every 3 bytes of binary become 4 ASCII characters. The hard part is knowing when base64 helps and when it makes things worse.

This guide covers the mechanics, the common use cases where base64 is the right call, and the common anti-patterns where it's a performance trap.

How Base64 Works

Base64 maps every 6 bits of binary data to one of 64 ASCII characters (A-Z, a-z, 0-9, +, /). Since 6 bits can represent 64 values and a byte is 8 bits, the math works out to 3 input bytes (24 bits) becoming 4 output characters (24 bits, 6 per character).

// Binary input: 3 bytes (24 bits)
01001000  01100101  01101100   → "Hel" in ASCII

// Split into 6-bit groups
010010  000110  010101  101100

// Map each 6-bit group to base64 alphabet
   S       G       V       s

// Result: "SGVs"

The 33% size increase: Every 3 bytes of binary become 4 bytes of base64 text. A 100KB image becomes ~133KB as base64. A 1MB file becomes ~1.33MB. This overhead is unavoidable — it's the mathematical cost of representing binary in ASCII.

Padding: If the input isn't a multiple of 3 bytes, base64 pads with = characters. One = for 2 leftover bytes, == for 1 leftover byte.

// JavaScript encoding/decoding
const encoded = btoa('Hello World');        // "SGVsbG8gV29ybGQ="
const decoded = atob('SGVsbG8gV29ybGQ=');  // "Hello World"

// For binary data (Uint8Array → base64)
function arrayBufferToBase64(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  bytes.forEach(b => binary += String.fromCharCode(b));
  return btoa(binary);
}

// base64 → Uint8Array
function base64ToArrayBuffer(base64) {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

Data URIs: Inline Small Assets

Data URIs embed file content directly in HTML or CSS using base64 encoding. They eliminate the HTTP request for that asset at the cost of increased document size.

<!-- Inline a small PNG icon -->
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" alt="1px dot">

<!-- Inline SVG as data URI (no base64 needed for text) -->
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Ccircle cx='12' cy='12' r='10' fill='%23333'/%3E%3C/svg%3E" alt="circle">

/* CSS: inline a small background image */
.icon {
  background-image: url('data:image/png;base64,iVBOR...');
}

The 2KB rule: Only inline assets smaller than ~2KB. Below this threshold, the HTTP request overhead (DNS, TLS, headers) costs more than the base64 size increase. Above 2KB, the 33% size penalty (which isn't compressed by gzip as efficiently as binary) makes a separate request faster.

SVG data URIs don't need base64. Since SVG is text (XML), you can URL-encode it instead: data:image/svg+xml,%3Csvg...%3E. This avoids the 33% overhead and is often smaller. Use this for inline SVG icons in CSS.

Embedding Binary Data in JSON

JSON has no binary type. When you need to include binary data (images, files, certificates) in a JSON payload, base64 is the standard approach.

// Sending an image in a JSON API request
const fileBuffer = await file.arrayBuffer();
const base64 = arrayBufferToBase64(fileBuffer);

await fetch('/api/upload', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filename: 'photo.jpg',
    contentType: 'image/jpeg',
    data: base64  // Base64-encoded image
  })
});

// Server-side decoding (Node.js)
const buffer = Buffer.from(req.body.data, 'base64');
await fs.writeFile(`uploads/${req.body.filename}`, buffer);

When this makes sense:

  • Small files (under 1MB) in API payloads where multipart form-data adds complexity
  • Inline image previews in API responses (thumbnails, avatars)
  • Storing binary blobs in JSON-based databases (MongoDB documents, DynamoDB items)

When this is wrong: Large files. Sending a 10MB video as base64 in JSON means a 13.3MB JSON body that must be fully parsed before the binary can be extracted. Use multipart/form-data or presigned URLs instead.

Email Attachments: MIME Base64

Email protocols (SMTP, IMAP) are text-based. Binary attachments are base64-encoded in the MIME body. This happens automatically — you never encode email attachments manually in application code.

Content-Type: multipart/mixed; boundary="boundary123"

--boundary123
Content-Type: text/plain

Please find the report attached.

--boundary123
Content-Type: application/pdf
Content-Disposition: attachment; filename="report.pdf"
Content-Transfer-Encoding: base64

JVBERi0xLjQKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovUGFnZXMg
MiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL1R5cGUgL1BhZ2VzCi9L
aWRzIFszIDAgUl0KL0NvdW50IDEKL01lZGlhQm94IFswIDAgNTk1LiA4
...
--boundary123--

The 33% overhead means email attachments are always larger than the original file in transit. A 7.5MB PDF attachment requires ~10MB of base64 data in the email. This is why email providers cap attachments at 25-50MB — the actual transfer size is 33% larger.

When NOT to Use Base64

Base64 is overused. These are the common anti-patterns:

1. Large images as data URIs:

<!-- Don't do this: 500KB image as base64 = 667KB inline -->
<img src="data:image/jpeg;base64,[...667KB of text...]" alt="Photo">

<!-- Do this: separate request, cacheable, smaller -->
<img src="photo.webp" alt="Photo">

The base64 version is 33% larger, can't be cached independently, bloats the HTML document, and can't be lazy-loaded. Separate files win for anything over ~2KB.

2. Cacheable assets: Base64 data URIs embedded in HTML or CSS are cached with the document. If the document changes (new build hash), the browser re-downloads the inline image even though it hasn't changed. Separate files have their own cache entries with independent lifetimes.

3. Performance-sensitive rendering: Base64 strings must be decoded to binary before the browser can render them. For images, this adds a decode step (parse base64 text → binary → decode image). Separate files skip the base64 parse step.

4. Large API payloads: Sending a 50MB video file as base64 in JSON creates a 67MB string that the JSON parser must handle in memory. Use multipart uploads or presigned URLs for large files.

Alternatives to Base64

Most situations where developers reach for base64 have better alternatives:

Blob URLs

Blob URLs create a temporary URL for in-memory binary data. They're perfect for displaying user-selected files before upload:

// Preview an image before upload (no base64 needed)
const file = inputElement.files[0];
const blobUrl = URL.createObjectURL(file);
previewImg.src = blobUrl; // Displays the image from memory

// Clean up when done
URL.revokeObjectURL(blobUrl);

// Creating a downloadable file from generated data
const blob = new Blob([csvContent], { type: 'text/csv' });
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'export.csv';
a.click();
URL.revokeObjectURL(downloadUrl);

Blob URLs vs data URIs: Blob URLs are a reference to binary data in memory — no encoding overhead. Data URIs contain the entire file as base64 text. Blob URLs are always faster and smaller for displaying binary content in the browser.

ArrayBuffer and TypedArrays

For processing binary data in JavaScript (reading file headers, parsing binary formats, crypto operations), use ArrayBuffer directly. No base64 encoding needed:

// Read file as ArrayBuffer
const file = inputElement.files[0];
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);

// Check magic bytes (file type detection)
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50;
const isJPG = bytes[0] === 0xFF && bytes[1] === 0xD8;

// Process binary data
const width = new DataView(buffer).getUint32(16); // PNG width at offset 16

multipart/form-data for File Uploads

Don't base64-encode files for upload. Use FormData, which sends binary directly:

// Wrong: base64 encode then send as JSON (33% overhead)
await fetch('/api/upload', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ file: btoa(fileContent) })
});

// Right: send binary directly via FormData (no overhead)
const formData = new FormData();
formData.append('file', fileInput.files[0]);
await fetch('/api/upload', {
  method: 'POST',
  body: formData
});

Base64url: The URL-Safe Variant

Standard base64 uses + and / characters, which conflict with URL syntax. Base64url replaces them with - and _ and omits padding (=).

// Standard base64:  "SGVsbG8r/w=="
// Base64url:        "SGVsbG8r_w"

// Where base64url is used:
// - JWT tokens (header.payload.signature, each part is base64url)
// - URL parameters with binary data
// - Cloudflare Workers KV keys

// Encoding/decoding in JavaScript
function toBase64url(str) {
  return btoa(str)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

function fromBase64url(str) {
  str = str.replace(/-/g, '+').replace(/_/g, '/');
  while (str.length % 4) str += '=';
  return atob(str);
}

JWT tokens use base64url for all three segments. If you're debugging a JWT and the base64 decode fails, you probably used atob() instead of a base64url decoder.

Base64 is a tool with a specific purpose: representing binary data as text when binary transport isn't available. Use it when the transport is text-only (JSON, URLs, email). Avoid it when binary transport is available (HTTP file upload, Blob URLs, ArrayBuffer processing).

The 33% overhead is small enough to ignore for tiny files (under 2KB) and devastating for large ones (10MB+ becoming 13MB+). The threshold is low — if you're debating whether to base64-encode something, you're probably past the point where it's a good idea.