Base64 Encoding in Node.js — Buffer vs atob vs btoa

Node.js gives you three separate APIs for Base64 encoding and decoding. Two of them look identical to the browser APIs. One of them is the right choice in virtually every situation.

I've reviewed pull requests where a junior dev used btoa() in a Node.js Lambda function, a senior architect insisted on atob() for a streaming pipeline, and a codebase migrated from one to the other three times in six months because no one checked whether Buffer was faster (it is, by a lot).

This guide covers all three APIs, when each one works, and why Buffer should be your default for server-side Node.js code.

The Buffer API — The Right Choice

Buffer is the Node.js-native way to handle binary data, and its Base64 support is straightforward:

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

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

The toString("base64") call does exactly what you expect — it takes whatever is in the buffer and produces a Base64-encoded string. The reverse reads from Base64 and outputs a UTF-8 string.

Buffer Handles Binary Data Naturally

Unlike btoa(), Buffer doesn't care whether the input is text, an image, a zip file, or a PDF:

const fs = require("fs");

// Read a binary file and Base64-encode it
const imageBuffer = fs.readFileSync("screenshot.png");
const base64Image = imageBuffer.toString("base64");

// Decode back to binary
const decodedBuffer = Buffer.from(base64Image, "base64");
fs.writeFileSync("copy.png", decodedBuffer);

This is the primary use case for Base64 in Node.js — file uploads, API payloads with binary attachments, and email attachments (MIME).

Buffer Supports Hex, Base64URL, and More

The same API works for other encodings too:

Buffer.from("hello").toString("hex");   // "68656c6c6f"
Buffer.from("hello").toString("base64url"); // "aGVsbG8gd29ybGQ" (no padding)

The base64url encoding (added in Node.js v15.7.0) produces URL-safe Base64 without +, /, or = padding. This is what you should use when encoding data that will appear in URLs, HTTP headers, or JWT tokens.

// Decode Base64URL
Buffer.from("aGVsbG8gd29ybGQ", "base64url").toString("utf8");
// "hello world"

The atob and btoa APIs — Available Since Node.js 16

Node.js added global atob() and btoa() functions in v16.0.0, matching the browser Web APIs. They're convenient and familiar:

btoa("hello world"); // "aGVsbG8gd29ybGQ="
atob("aGVsbG8gd29ybGQ="); // "hello world"

The problem is that these are string-to-string functions. They don't accept binary input, and they fail on characters outside the Latin1 range:

btoa("hello 世界");
// InvalidCharacterError: 'btoa' failed: The string to be encoded contains characters outside of the Latin1 range.

This matters in Node.js because you're often reading files from disk, receiving binary data from streams, or processing network payloads. atob() and btoa() simply aren't designed for those scenarios.

Where They're Useful

atob() and btoa() are fine for quick debugging in the REPL:

$ node
> btoa("debug:1234")
'ZGVidWc6MTIzNA=='
> atob("ZGVidWc6MTIzNA==")
'debug:1234'

They're also useful in browser-and-Node isomorphic code, where you want a single code path that works in both environments. But the browser's atob() has the same Latin1 limitation.

Performance Comparison

Base64 encoding is CPU-bound, and there's a measurable difference between the APIs:

const Benchmark = require("benchmark");
const suite = new Benchmark.Suite();

const data = "x".repeat(1024 * 1024); // 1 MB string

suite
  .add("Buffer.toString('base64')", () => {
    Buffer.from(data).toString("base64");
  })
  .add("btoa()", () => {
    btoa(data);
  })
  .on("complete", function() {
    console.log("Fastest: " + this.filter("fastest").map("name"));
  })
  .run();

In practice, Buffer is significantly faster for large payloads because it operates directly on Node.js internal memory, while btoa() converts the string through the V8 API layer. For small strings (under 1 KB), the difference is negligible. For multi-megabyte file uploads, Buffer can be 2-3x faster.

Handling Unicode Correctly

If you must use atob()/btoa() with Unicode text, you need a wrapper that converts through UTF-8 bytes:

function toBase64(str) {
  const bytes = new TextEncoder().encode(str);
  return btoa(String.fromCodePoint(...bytes));
}

function fromBase64(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);
}

With Buffer, this is unnecessary:

Buffer.from("hello 世界").toString("base64");
// "aGVsbG8g5LiW55WM"
Buffer.from("aGVsbG8g5LiW55WM", "base64").toString("utf8");
// "hello 世界"

Real-World Patterns

HTTP Basic Auth Header

function basicAuthHeader(username, password) {
  const credentials = `${username}:${password}`;
  const encoded = Buffer.from(credentials).toString("base64");
  return `Basic ${encoded}`;
}

basicAuthHeader("admin", "supersecret");
// "Basic YWRtaW46c3VwZXJzZWNyZXQ="

File Upload API Payload

const fs = require("fs");

function buildUploadPayload(filePath, filename) {
  const content = fs.readFileSync(filePath).toString("base64");
  return JSON.stringify({
    filename,
    content,
    mimeType: "application/pdf"
  });
}

If the API expects standard Base64 (not Base64URL), stick with Buffer.toString("base64"). If it expects URL-safe Base64, use Buffer.toString("base64url").

Reading Environment Variables

Environment variables that contain Base64-encoded secrets (like TLS certificates or SSH keys) often include newlines:

const pemBase64 = process.env.TLS_CERT_BASE64;

// PEM files typically have line breaks — remove them
const clean = pemBase64.replace(/\n/g, "");
const certBuffer = Buffer.from(clean, "base64");

For a quick debugging session to inspect what's inside a Base64 string, the Base64 Encoder & Decoder tool handles both standard and URL-safe formats. It's faster than writing a throwaway Node.js script.

Migration Guide: Switching to Buffer

If your codebase currently uses atob()/btoa() and you want to migrate to Buffer:

BeforeAfter
btoa(str)Buffer.from(str).toString("base64")
atob(str)Buffer.from(str, "base64").toString("utf8")
btoa(unicodeStr)Buffer.from(unicodeStr).toString("base64")
atob(b64).split("")...Buffer.from(b64, "base64") (returns binary)

The Buffer version handles Unicode, binary, and large payloads without edge cases.

FAQ

Should I use atob/btoa in Node.js or stick with Buffer?

Use Buffer for production Node.js code. atob() and btoa() are fine for quick REPL debugging but fail on binary data and Unicode input. Buffer handles everything — text, images, PDFs — without special cases.

Does Node.js support Base64URL encoding?

Yes. As of Node.js v15.7.0, Buffer.from(data).toString("base64url") produces URL-safe Base64 without +, /, or = characters. Use Buffer.from(data, "base64url") to decode.

Why does my Base64 decoding return garbled text?

You're probably decoding binary data (an image or PDF) as if it were UTF-8 text. The bytes are valid — they're just not meaningful when rendered as a string. Write the decoded buffer directly to a file instead of calling .toString("utf8").

Can I Base64-encode a stream in Node.js?

Yes, but you need to buffer the stream first. Base64 encoding requires the entire input to produce output, so streaming Base64 isn't truly zero-latency. Use Buffer.concat to collect stream chunks, then encode the combined buffer.

Is btoa() faster than Buffer.toString("base64")?

For strings under 1 KB, the difference is negligible. For multi-megabyte payloads, Buffer is measurably faster because it operates on Node.js internal memory directly.


For everyday debugging and quick inspection of Base64 strings, the Base64 Encoder & Decoder tool handles standard Base64, URL-safe Base64, and large payloads in the browser. If you're working with JWTs, pair it with the JWT Decoder for automatic token splitting and signature verification. If you're debugging URL encoding issues, the URL Encoder & Decoder tool complements your toolkit.