Base64 Encoding in JavaScript/Node.js: A Complete Guide
You have data that needs to become text. A file that must ride inside a JSON field, an image that wants to live in a CSS file, a secret that will sit in an environment variable, a token that will travel through a query string. The answer in JavaScript and Node.js is almost always the same: Base64. This article is the packing manual, from the first byte you hold to the moment your encoded string leaves the machine.
The home page of this site explains the format in full detail, the alphabet, the math, the padding, so here it is in one sentence: every three bytes become four printable characters, which is why your output will be about 33 percent larger than the input. Keep that in your pocket, because it is the reason every section of this article exists, and it is the number your storage bill is calculated in.
The comforting part: you install nothing. Every modern browser ships btoa() and the newer Uint8Array.toBase64(), and every Node.js version that matters carries the Buffer class with a 'base64' mode and, since version 15.7.0, a first-class 'base64url' mode. The art is in knowing which input shape you hold, which alphabet the destination demands, and which line-wrapping rules the old formats still enforce.
Know Your Input Before You Encode
Every encoding question starts with the same one: what exactly are you holding? A JavaScript string is UTF-16 text, a Buffer is a byte array, and the right call depends on which one you have:
| You are holding | Call this | Notes |
|---|---|---|
| An ASCII-only string (characters under 256) | btoa(string) |
Fastest path in browsers and Node.js 16+, but it stops at the first character that does not fit in a byte |
| Any Unicode string | TextEncoder to bytes, then a base64 call |
The UTF-8 bridge; the only safe path for accented letters and emoji |
| A Buffer or Uint8Array | buffer.toString('base64') or bytes.toBase64() |
Node.js workhorse, and the ES2027 method in modern browsers and Node.js 25+ |
Three examples, one per row of the table:
// ASCII-only text: the legacy shortcut (browsers and Node.js 16+)
console.log(btoa('hello world')); // "aGVsbG8gd29ybGQ="
// Any text in Node.js: Buffer reads UTF-8 by default
const { Buffer } = require('node:buffer');
console.log(Buffer.from('héllo ⛳', 'utf8').toString('base64')); // "aMOpbGxvIOKbsw=="
// Bytes you already own
console.log(Buffer.from([1, 2, 3, 4]).toString('base64')); // "AQIDBA=="
console.log(new Uint8Array([1, 2, 3, 4]).toBase64()); // "AQIDBA==" (ES2027 runtimes)
Notice the second example: the same text produces a different Base64 string depending on the charset you encode it in. That is not a bug, it is the whole game. The Base64 layer encodes bytes, and a string becomes bytes only once you have picked a charset, so "encode this text" always secretly means "encode the UTF-8 bytes of this text" (or the Latin-1 bytes, if you say so).
The Unicode Wall And The Bridges Over It
btoa() is the oldest API in the room, and its contract is a 1990s one: each character of the input string must fit in a single byte, code points 0 to 255. Anything above that, an emoji, an accented Cyrillic letter, a Chinese character, throws:
try {
btoa('héllo ⛳');
} catch (error) {
console.log(error.name); // "InvalidCharacterError"
console.log(error.message); // mentions characters outside of the Latin1 range
}
The fix is to stop thinking in characters and start thinking in bytes. TextEncoder (a global in every browser and in Node.js) turns the string into its UTF-8 byte sequence, you lift those bytes into a Latin-1 string, and btoa() gets exactly what it promised to handle:
function encodeUnicode (text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
console.log(encodeUnicode('héllo ⛳')); // "aMOpbGxvIOKbsw=="
console.log(encodeUnicode('héllo ⛳') === Buffer.from('héllo ⛳', 'utf8').toString('base64')); // true, same bytes
You will also meet the older idiom in codebases, and it works the same way underneath: btoa(unescape(encodeURIComponent(text))). The encodeURIComponent call produces percent-encoded UTF-8 bytes, and unescape turns the percent escapes back into raw characters. Both escape and unescape are legacy functions, so new code should prefer the TextEncoder bridge, but when you inherit the old form, now you know exactly what it is doing instead of shrugging.
In Node.js the wall is mostly a non-event, because Buffer.from(text) assumes UTF-8 and does the byte conversion for you in the same call. The bridge matters most in the browser, where btoa() is the legacy option and the UTF-8 step is yours to make explicitly.
Bytes In, Letters Out: Buffers, Padding And Flavors
Once you have bytes, the encoding side of Node.js is one method: toString('base64'). It handles the group math, the padding, everything, and it always produces canonical output in the RFC 4648 sense, meaning the unused pad bits of the last group are zero:
const { Buffer } = require('node:buffer');
const fox = Buffer.from('The quick brown fox jumps over the lazy dog');
console.log(fox.length); // 43 bytes
console.log(fox.toString('base64')); // "VGUgcXVpY2sgYnJvdyBmb3gganVtcHMgb3ZlciB0aGUgbGF6eSBkb2c="
console.log(fox.toString('base64').length); // 60 characters, the 33 percent tax in action
The padding at the end is doing real work, not decoration. A final group with one leftover byte becomes two Base64 characters plus two =, and a group with two leftover bytes becomes three characters plus one =. Whether your output may carry that padding depends on the destination, and that is the difference between the two Base64 flavors you will use daily:
const one = new Uint8Array([72]);
console.log(one.toBase64()); // "SA==" (ES2027, padding included)
console.log(one.toBase64({ omitPadding: true })); // "SA"
console.log(Buffer.from([72]).toString('base64url')); // "SA", Node drops the padding in base64url mode
Remember the ratio when you size things: three bytes in, four characters out, so 1 MB of data becomes about 1.33 MB of text, and if you wrap the text into lines for email or PEM, the line breaks add a few percent on top.
Making Bytes Cross The Wire
The most common wire problem in JavaScript is that JSON has no bytes. It has strings, and the string that can safely travel through any JSON parser, any HTTP proxy and any logging system is the Base64 one. The pattern is the same on both ends of the connection: encode at the boundary, decode at the boundary, hold bytes in between:
const fs = require('node:fs');
const photo = fs.readFileSync('./photo.jpg', 'base64');
const payload = JSON.stringify({
name: 'photo.jpg',
contentType: 'image/jpeg',
data: photo
});
console.log(payload.startsWith('{"name":"photo.jpg"')); // true, the file now rides inside ordinary JSON
Know when to fight this pattern. If your transport already supports binary, use it: a multipart/form-data upload sends the raw file with no size tax, a WebSocket frame carries raw bytes, and a Postgres bytea column stores them natively. Base64 in a place where raw bytes were allowed is pure overhead, the 33 percent tax with nothing to show for it. Base64 earns its keep when the channel is text-only: JSON APIs, email bodies, environment variables, URL query strings, and the many bridges (mobile SDKs, desktop apps, chat systems) that will only let text through.
Data URLs: Pictures That Live In Text
The data URL, the data:image/png;base64,... string, is Base64 wearing a MIME label, and it is the reason you can put an entire image inside a single HTML attribute. The RFC from 1998 that defined the scheme even says it is "only useful for short values", because early HTML had a 1024-character limit on attribute values. Modern browsers laugh at that limit and happily render megabyte-size data URLs, which is both a superpower and a trap.
In the browser the canvas API does the whole job for you, pixels in, data URL out:
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext('2d');
context.fillStyle = '#ff0000';
context.fillRect(0, 0, 1, 1);
const dataUrl = canvas.toDataURL('image/png'); // "data:image/png;base64,iVBOR..."
console.log(dataUrl.slice(0, 22)); // "data:image/png;base64,iV"
And in any runtime, including Node.js, building one is just string concatenation with the metadata in the right place: a data: prefix, the media type, the optional ;base64 marker, a comma, and the payload. Without the ;base64 marker the payload is expected to be percent-encoded text instead, which is the reason the marker exists:
const { Buffer } = require('node:buffer');
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', 'base64');
const dataUrl = 'data:image/png;base64,' + png.toString('base64');
console.log(dataUrl.startsWith('data:image/png;base64,')); // true
The honest trade-offs: a data URL is part of the document, so it is not cacheable as its own resource, it counts against the size of the HTML or CSS it sits in, and the DOM has to parse and hold it. For a 20 kilobyte icon that is a bargain. For a 4 megabyte hero image, ship the file over HTTP where caching and compression both work, and keep the data URL for the small stuff.
Files That Travel As Strings
File to Base64 is a two-step dance that both runtimes compress into a single call. In Node.js the filesystem accepts 'base64' as a read encoding, and the write side accepts it too:
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
const base64 = fs.readFileSync('./report.pdf', 'base64');
console.log(base64.length); // the file, about 33 percent heavier
fs.writeFileSync('./report.pdf.b64', base64, 'utf8');
const copy = Buffer.from(base64, 'base64');
fs.writeFileSync('./report.copy.pdf', copy);
In the browser the FileReader does the same job, with one twist: its data-reading mode hands you a data URL, so you slice off the prefix to get the bare Base64 payload:
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', () => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result; // "data:application/pdf;base64,..."
const payload = {
name: fileInput.files[0].name,
data: dataUrl.slice(dataUrl.indexOf(',') + 1)
};
console.log(payload.data.length); // the file, ready for a JSON request
};
reader.readAsDataURL(fileInput.files[0]);
});
If you need the raw Base64 without the data URL prefix in the browser, file.arrayBuffer() followed by Uint8Array.toBase64() (on runtimes that have it) skips the prefix entirely and is the cleaner path for upload pipelines.
base64url: The Alphabet That Survives URLs
Classic Base64 carries two characters that URLs are allergic to. The + becomes a space whenever a query string is form-decoded, the / is a path separator, and the = padding looks like an assignment. The URL and filename safe variant from RFC 4648 section 5, base64url, swaps the two specials for - and _ and drops the padding whenever the length is known from context. It is the alphabet of JWTs, OAuth tokens and deep links, and it deserves a dedicated place in your mental toolkit.
Node's Buffer has spoken this dialect since version 15.7.0, and the encoding side is one argument:
const { Buffer } = require('node:buffer');
const classic = 'qL8R4QIcQ/ZsRqOAbeRfcZhilN/MksRtDaErMA==';
console.log(Buffer.from(classic, 'base64').toString('base64url')); // "qL8R4QIcQ_ZsRqOAbeRfcZhilN_MksRtDaErMA"
Two things to notice. The + became a -, the / became an _, and the padding vanished, because base64url mode omits it by design. And the IETF is explicit that this is a different encoding, not the same one with a costume, so when a spec says "base64url", you should produce base64url, not classic Base64 with a find-and-replace. The ES2027 method makes the same choices explicit options, and its omitPadding flag gives you the padding back when the context demands it:
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url', omitPadding: true })); // "q_8"
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url' })); // "q_8=", two bytes need one pad character
On runtimes without either, the conversion is a two-character swap plus a padding trim, and it is one of the most copy-pasted snippets in the JavaScript world:
const toUrlSafe = (value) => value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
console.log(toUrlSafe('qL8R4QIcQ/ZsRqOAbeRfcZhilN/MksRtDaErMA==')); // "qL8R4QIcQ_ZsRqOAbeRfcZhilN_MksRtDaErMA"
Use base64url for anything that will live in a URL, a query string, a filename, or a token standard. Use classic Base64 for MIME bodies, data URLs and anything that will never meet a percent-decoder. Mixing them up is the most common interop bug in this whole format.
Sealing Credentials: Basic Auth, JWTs And PKCE
Three authentication corners of the web are built on Base64, and all three are cheap to build by hand once, which is a good thing because knowing what happens under the library is what keeps you calm when the library surprises you.
First, HTTP Basic authentication (RFC 7617): the client sends the scheme word Basic plus the Base64 of user-id:password. One line, and one serious warning attached:
const { Buffer } = require('node:buffer');
console.log('Basic ' + Buffer.from('octo:cat').toString('base64')); // "Basic b2N0bzpjYXQ="
Base64 here is obfuscation, not security. Anyone who can read the header can read the password, so this scheme is only acceptable over HTTPS, and even then it is a legacy pattern: prefer tokens. Second, the JWT: the first two dot-separated parts are Base64url of plain JSON, and the third is the signature. Building an HMAC-SHA256 token by hand is a handful of lines of the built-in crypto module:
const crypto = require('node:crypto');
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'octocat', exp: 1893456000 })).toString('base64url');
const signature = crypto.createHmac('sha256', 'topsecret').update(header + '.' + payload).digest('base64url');
const token = header + '.' + payload + '.' + signature;
console.log(token.split('.').length); // 3 parts, padding-free base64url throughout
Notice the details that make or break a token: no padding anywhere (RFC 7515 omits it), the signature is computed over the literal string header + '.' + payload, not over the parsed objects, and the whole thing is only as secret as the key. In production you will use a library, jose (zero dependencies, browser and Node.js) or jsonwebtoken (Node.js), but they are running these exact calls under the hood. Third, PKCE (RFC 7636), the extension that lets public clients like SPAs and mobile apps log in safely: the client generates a high-entropy code_verifier, publishes BASE64URL(SHA256(verifier)) as the challenge, and proves possession of the verifier at the token exchange. Randomness matters, so the verifier comes from the crypto module, never from Math.random():
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
console.log(verifier.length, challenge.length); // 43 43, both inside the allowed 43-128 range
Old Mail Needs Wrapping: MIME And PEM Armor
Two of the oldest Base64 formats in the world still enforce line lengths, and both are about 30 years old. MIME, the email standard from RFC 2045, wraps its Base64 at 76 characters per line and requires the lines to end with CRLF, a relic of the 8-bit-clean SMTP days when very long lines broke real mail servers. RFC 7468, which writes down the PEM rules for certificates and keys, is stricter still: generators must wrap at exactly 64 characters per line, the final line shorter, framed by -----BEGIN and -----END armor lines that name the content.
The wrapping itself is a one-liner, and the armor is a template:
const wrap = (base64, width) => base64.match(new RegExp('.{1,' + width + '}', 'g')).join('\r\n');
const certBase64 = Buffer.from('x'.repeat(150), 'utf8').toString('base64'); // 200 characters
console.log(wrap(certBase64, 76).split('\r\n').map((line) => line.length).join(', ')); // "76, 76, 48"
console.log(wrap(certBase64, 64).split('\r\n').map((line) => line.length).join(', ')); // "64, 64, 64, 8"
const armor = (label, body) => '-----BEGIN ' + label + '-----\r\n' + wrap(body, 64) + '\r\n-----END ' + label + '-----\r\n';
console.log(armor('CERTIFICATE', 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='));
// -----BEGIN CERTIFICATE-----
// QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=
// -----END CERTIFICATE-----
Two practical notes. When you produce MIME or PEM, wrap it, because strict consumers (mail gateways, OpenSSL-era tools, Java key stores) will reject a 4000-character one-line Base64 blob. When you consume it, you usually do not need to, because Node's decoder skips all whitespace: the same Buffer.from(wholePem, 'base64') handles the armor lines and line breaks, since non-alphabet characters are simply ignored. That asymmetry is a gift, but it does not mean you can skip the armor-stripping step when the Base64 is going somewhere that does not skip anything, like a DER parser.
Where Encoded Data Lives: Env, Config And Databases
Base64 is also a storage format, which is both convenient and dangerously easy to mistake for security. Environment variables are the classic home: several secret managers, CI systems and the npm CLI itself hand you Base64-encoded values, and the decode is a one-liner:
const { Buffer } = require('node:buffer');
const stored = process.env.API_KEY_B64; // "c3VwZXItc2VjcmV0"
console.log(Buffer.from(stored, 'base64').toString('utf8')); // "super-secret"
Say the important sentence out loud: encoding is not encryption. A Base64 "secret" in an environment variable, a .env file or a Kubernetes secret (k8s stores its secrets as Base64 in the API and in etcd, and the docs repeat it constantly) is readable by anyone who can read the process environment, the file, or the cluster. Use Base64 there because the transport (shell, YAML, JSON) is text-only, never because you believe it hides anything.
In databases, Base64 is the standard bridge for binary inside JSON document stores, because a jsonb column or a MongoDB document has no byte type of its own:
const document = {
name: 'logo',
mime: 'image/png',
data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')
};
console.log(JSON.stringify(document)); // {"name":"logo","mime":"image/png","data":"iVBORw=="}
Store the media type next to the payload, as the example does, and you will thank yourself a year from now when someone asks what the bytes are. If your database has a native binary type (Postgres bytea is the reference example), prefer it: the bytes cost nothing extra, and you skip the 33 percent tax forever.
Encoding Streams Without Splitting Groups
Base64 works in three-byte groups, so an encoder that receives arbitrary chunks must carry its remainder: one or two bytes that cannot form a group yet need to wait for the next chunk before they can be encoded. Do the math per chunk and emit only complete groups, and the output is byte-identical to encoding the whole stream at once:
const { Transform } = require('node:stream');
const { Buffer } = require('node:buffer');
function base64Encoder () {
let pending = Buffer.alloc(0);
return new Transform({
transform (chunk, _encoding, done) {
pending = Buffer.concat([pending, chunk]);
const whole = Math.floor(pending.length / 3) * 3;
this.push(pending.subarray(0, whole).toString('base64'));
pending = pending.subarray(whole);
done();
},
flush (done) {
if (pending.length > 0) {
this.push(pending.toString('base64'));
}
done();
}
});
}
let output = '';
const encoder = base64Encoder();
encoder.on('data', (part) => { output += part; });
encoder.on('end', () => {
console.log(output); // "aGVsbG8gd29ybGQsIHRoaXMgaXMgYSBzdHJlYW0h"; identical to one big toString('base64')
});
encoder.end(Buffer.from('hello world, this is a stream!'));
The flush callback is the detail everyone forgets: the final one or two bytes, the ones that never found a partner in a regular chunk, get their padding and are pushed out at the end. The same carry logic is what you will mirror on the decoding side, except that there the ES2027 API gives it to you for free: setFromBase64() with "stop-before-partial" stops exactly at group boundaries and tells you how many characters it consumed.
Big Files And The Memory Bill
Base64 is generous with space, so big files need a strategy. A 1 GB file becomes about 1.37 GB of Base64 text, and a JavaScript string stores UTF-16, two heap bytes per character, so that text alone wants roughly 2.7 GB of memory before your decoded Buffer arrives. The ceiling is explicit in Node: buffer.constants.MAX_STRING_LENGTH is 536870888 characters, a little over 512 MiB of text, which decodes to about 400 MB of bytes. Beyond that, a single string is not an option, and streaming is the only game in town:
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
let carried = Buffer.alloc(0);
const source = fs.createReadStream('./video.mp4', { highWaterMark: 64 * 1024 });
source.on('data', (chunk) => {
const joined = Buffer.concat([carried, chunk]);
const whole = Math.floor(joined.length / 3) * 3;
process.stdout.write(joined.subarray(0, whole).toString('base64'));
carried = joined.subarray(whole);
});
source.on('end', () => {
if (carried.length > 0) {
process.stdout.write(carried.toString('base64'));
}
process.stdout.write('\n');
});
The pattern is the stream encoder from the previous section, flattened: read in 64 KiB chunks, carry the 1-to-2-byte remainder, emit complete groups, flush the tail. The memory footprint stays around one chunk plus one remainder, whatever the file weighs. And if the receiving end can accept binary, ask yourself why you are paying the tax at all.
One-Liners For The Terminal
Node doubles as a command-line Base64 encoder, which is handy when you are packaging a config value, debugging an API, or moving a small file between machines through a chat message:
# Encode a file to classic Base64 on stdout
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64"))' notes.txt
# The URL-safe variant, padding dropped
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64url"))' notes.txt
# Read from stdin, what pipes are for
echo -n "hello world" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(Buffer.from(d,"utf8").toString("base64")))'
None of the three adds a trailing newline of its own, which keeps the output clean for copy-paste and for $(...) substitution in shell scripts. If you want a pretty printed file with wrapped lines, pipe the result into your editor of choice, or add a \n at the end of the one-liner.
Pitfalls That Cost Developers Hours
Every one of these has cost a real afternoon in a real codebase:
- The Unicode wall:
btoa('héllo ⛳')throwsInvalidCharacterErrorbecause the golf flag does not fit in a byte. The fix is the UTF-8 bridge:TextEncoderto bytes first, then encode. In Node.js you skip the problem entirely withBuffer.from(text), which assumes UTF-8. - The legacy idiom: old code full of
btoa(unescape(encodeURIComponent(x)))works, butescapeandunescapeare deprecated legacy functions. When you refactor that code, replace it with theTextEncoderbridge and the behavior stays identical. - The missing encoding argument, in reverse: the decode-side pitfall is
Buffer.from(str, 'base64')without the mode; the encode-side twin is assumingBuffer.from(someString)does anything special with Base64. It does not. Without an explicit encoding it builds a Buffer from the string's UTF-8 bytes, and your "encoded" output is the Base64 of the string's letter bytes, which is almost never what was wanted. Be explicit in both directions. - The padding mismatch: JWTs and most token standards want base64url without padding, MIME wants classic Base64 with padding, and the two are easy to cross. A padded
=inside a JWT part breaks strict verifiers; a missing padding where the length is unknown breaks lazy decoders. Match the standard, not your habit. - The non-canonical tail: RFC 4648 requires the unused pad bits of the final group to be zero. The built-in encoders all produce canonical output, but a hand-rolled encoder that shifts bits by hand can leave junk in those bits, and a strict decoder will reject your payload for no apparent reason. If you write your own encoder, test against the RFC 4648 test vectors, not just your own data.
- The forgotten wrap: MIME wants 76-character lines and PEM wants 64, and strict consumers (mail gateways, Java key tooling) reject a one-line blob. The reverse is rarer but real: some parsers are line-oriented, and a missing CRLF at the end of a PEM file has broken more builds than any bug in the Base64 itself.
- The security illusion: Base64 in an environment variable, a
.envfile or a Kubernetes secret is not encryption. It decodes with one line of code, in any language, by anyone who can read the file or the cluster. Treat it as a transport costume, keep the real controls (permissions, TLS, key rotation) doing the protecting. - The JSON bloat: Base64 inside JSON costs 33 percent plus escaping, and a 5 MB upload becomes a 6.7 MB string that your JSON parser must copy into memory. For anything file-sized over HTTP,
multipart/form-dataor a raw binary body is the better transport, and Base64 is for when the channel is text-only. - The heap bill: an encoded string is UTF-16 in the JavaScript heap, two bytes per character, and the decoded or source Buffer is a second copy of the data. A 100 MB file briefly means about 270 MB of string plus 100 MB of Buffer. Stream the big ones, and keep the encoded form referenced for as short a time as the code allows.
- The legacy globals in Node: Node's own documentation marks
btoa()andatob()as Stability 3, Legacy, and tells you to useBufferinstead. In a browserbtoa()is a perfectly fine tool for ASCII text; in Node.js, reach for the Buffer and leave the globals to the polyfill-shaped code that needs them.
How JavaScript Learned To Pack Bytes
The browser side is a long, quiet story. btoa() was specified in the HTML5 draft in the late 2000s and has sat in every major browser ever since, unchanged in behavior, with its one-byte-per-character contract and its always-padded output. It predates typed arrays by a decade, which is why it still thinks in "binary strings". The modern half of the story is very recent: the TC39 proposal that added native Base64 to typed arrays (along with hex) standardized as part of ES2027, and it landed in Firefox 133 and Safari 18.2 in 2024, in Chrome 140 on September 2, 2025, and was then declared Baseline Newly available. Bun shipped the same methods in version 1.2 in January 2025.
Node.js packed bytes on a different clock. The Buffer class became a global in version 0.1.103, in the summer of 2010, five years before Node 1.0, and toString('base64') was the encoder of choice for over a decade, with the alphabet quirks of that era (it already accepted the URL-safe characters when decoding, a bilingual habit the spec never asked for). Version 15.7.0 in January 2021 added the 'base64url' mode as a first-class encoding name, Node 16 that same year added the browser's btoa()/atob() globals (marked Legacy immediately), and Node 22 in 2024 brought a performance upgrade to the base64 and base64url paths. Then Node 25, released on October 15, 2025, upgraded V8 to 14.1 and brought the ES2027 methods, toBase64() with its omitPadding option and setFromBase64() for the other direction, into the runtime. For runtimes that cannot keep up, core-js and es-shims ship polyfills, and the small base64-js package (three functions, zero dependencies) has quietly carried the ecosystem for years as a transitive dependency.
The format they serve is older than all of it. The alphabet was standardized for Privacy-Enhanced Mail in 1993, MIME adopted it a year later with its 76-character wrap, RFC 3548 consolidated the Base-N family in 2003, and RFC 4648 added the URL-safe variant in 2006. A decade later, RFC 7515 and 7519 made padding-free base64url the backbone of every JWT, and RFC 7636 put it in OAuth's PKCE flow. The encoders in this article are the last mile of a format that is thirty years old and still gaining passengers.
Worth Knowing At A Party
btoa('GIF89a')returns"R0lGODlh", the entire magic header of a GIF in eight characters. It is the smallest "hello" a binary file can say in Base64, and it is the first example in the Wikipedia article for a reason.toBase64()has anomitPaddingoption thatbtoa()could never have, because the Web API contract pads unconditionally. Two decades of the same alphabet, and the newer API can do one thing the older one was never allowed to.- One alphabet, two official line lengths: MIME wraps at 76, PEM at 64. Same 64 characters, same padding, two different 30-year-old opinions about how wide a line of text may be.
- The 33 percent number is exact: four characters per three bytes is a 4/3 ratio, and RFC-era email added roughly another 4 percent for the line breaks. Your "small" configuration string is 37 percent of nothing extra.
- The little
base64-jspackage pulls in over 100 million downloads a week on npm, almost all of it hidden inside other packages' dependency trees. Base64 is the most smuggled code in the JavaScript ecosystem. - Small Buffers are not allocated one at a time: Node carves them out of a shared 65536-byte pool (
Buffer.poolSize), which is why Buffer creation is fast, and why the "unsafe" allocation variants exist for the cases where the previous tenant's data does not matter. - The RFC that defined data URLs in 1998 warns they are "only useful for short values", citing a 1024-character HTML attribute limit. Modern browsers embed megabyte-size images as data URLs in the same attributes, which is either progress or hubris, depending on your hero image.
- Unix password hashes use their own Base64-flavored alphabet,
./0-9A-Za-zwithout padding. You will meet it in the$2b$bcrypt strings that JavaScript projects store for user passwords, and it is a good reminder that "Base64" in a security context is a family, not a single format. - Node's decoder accepts
-,_,+and/in both'base64'and'base64url'modes, four characters, one table. The encoder, of course, only speaks the dialect you asked for.
Half Of A Round Trip
Encoding Base64 in JavaScript and Node.js comes down to three decisions: which bytes do you hold (a string needs a charset, a Buffer needs none), which alphabet does the destination demand (classic for MIME and data URLs, base64url for tokens and URLs, padding optional by context), and which line rules does the format still enforce (76 for email, 64 for PEM, none for JSON). Answer those and the built-ins do the rest: Buffer.toString() in Node, btoa() plus the UTF-8 bridge in the browser, and Uint8Array.toBase64() in the modern runtimes that finally got one.
And every package you seal here, someone else will one day open. The decoding side has its own set of traps: the lenient decoder that swallows garbage without a sound, the binary-string costume that atob() hands you, the charset decisions that happen on the reader's side of the wall, and the streaming logic that mirrors the carry pattern you just learned. That story, with code examples for every step, is covered in depth in the related Base64 decoding article on our sister site. Read it next, because the traps on that side of the alphabet are quieter, and quieter is exactly how they win.
Last updated: 2026-08-29
Related article: Base64 Decoding in JavaScript/Node.js: A Complete Guide