Base64 Encoding in JavaScript/Browser: A Complete Guide
You have something that needs to travel, and the road is only wide enough for plain ASCII. It might be an image that belongs inside a JSON response, a configuration object that has to ride along in a URL, a token whose three segments are dots and letters, a file that an API insists arrive as a Base64 string inside a JSON body. Base64 is the toll booth for exactly this situation, and the home page of this site already walks through the format - four printable characters standing in for every three bytes, with = padding to finish the group - so here is the one number to keep in your head while reading: encoding is the growing direction. Every three bytes you hand in come back as four characters, a size tax of about 33 percent, collected in bandwidth, storage, and memory. Use Base64 when the channel demands printable text, and know exactly what that tax costs you.
The encouraging news is that the browser has always been able to do this job without a single package. btoa() has been shipping since the early 2000s, TextEncoder turned your real Unicode text into honest bytes a decade ago, and in 2025 the platform finally added Uint8Array.toBase64(), which encodes byte arrays directly with an option for the URL-safe alphabet. This article is the decision map: which tool for which job, where the sharp edges hide (they all hide in one place, and you will meet it in the first code block), and the concrete recipes for the places you will actually be asked to produce Base64.
Choosing Your Encoder
There is no single "the" encoder anymore, and reaching for the wrong one is how the classic bugs get born. The table below is the entire decision tree:
| Situation | Reach for |
|---|---|
| Plain ASCII text, one-off value | btoa(text) |
| Real text with accents, emoji, CJK | new TextEncoder().encode(text), then btoa or toBase64 |
Bytes already in a Uint8Array |
bytes.toBase64() on 2025+ browsers, the chunked btoa bridge elsewhere |
| URLs, JWTs, filenames | toBase64({ alphabet: 'base64url', omitPadding: true }) |
| Old browsers or a shared codebase | js-base64, or the classic TextEncoder + btoa recipe |
The pattern underneath the table: btoa() only reads single-byte characters, so anything that is not ASCII must become a byte array first, and that byte array is what the modern APIs were built around. Keep "text becomes bytes, bytes become Base64" in your head and every recipe in this article is the same two steps with different names on them.
btoa and the Latin1 Boundary
btoa(stringToEncode) - binary string to ASCII string - is the original encoder, available in every browser that matters (Chrome 4, Firefox 1, Safari 3, IE 10 and up, all worker scopes, and Node from version 16). Its contract has one clause, and that clause is where everything goes wrong: every character in the input must have a code point from 0 to 255. The function reads code points, not UTF-8 bytes, so "é" (code point 233) sails through while "你" (code point 20320) throws a DOMException named InvalidCharacterError before a single character is encoded. The boundary is not "ASCII", it is not "Unicode", it is precisely 256, and it includes the control characters at the bottom - encoding a NUL byte is legal and meaningful, which is one of the reasons the function exists at all.
The full behavior, row by row:
| Input | Result |
|---|---|
"Hello, World!" |
"SGVsbG8sIFdvcmxkIQ==" - the textbook case |
"" (empty string) |
"" - nothing in, nothing out |
"\u0000" (NUL) |
"AA==" - control characters are first-class citizens |
"a\u00e9z" (é, code point 233) |
"Yel6" - the whole Latin1 range passes |
"\u0100" (code point 256) |
throws InvalidCharacterError - one step past the boundary |
"h\u4e16" (你, code point 20320) |
throws InvalidCharacterError - and so does every emoji, because they are all far above 255 |
Two practical notes. The error message differs by engine - Firefox says "String contains an invalid character", Chrome says the string "contains characters outside of the Latin1 range" - so in any defensive code you catch on the exception name. And the throw happens on the first offending character, not at the end: btoa does not encode half the string and apologize. When you do want the Latin1 behavior on purpose (encoding a byte string that was deliberately built from 0-255 code points), the function is doing exactly what you asked, and the table above is its whole personality.
The Bytes Bridge
So the question becomes: how do real data - the UTF-8 bytes of your text, the contents of a file, the output of a canvas - get into btoa()'s input? The answer is the "bytes bridge": a JavaScript string in which each character holds one byte value, the same trick the decoders produce and which btoa understands natively. The naive version is a loop:
function bytesToBase64 (bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
Correct, but string concatenation in a loop is slow for big files, and the popular shortcut - String.fromCharCode.apply(null, bytes), which feeds the whole array as arguments in one call - has a hard cliff. Function calls have a limit on the number of arguments, and it is reached well before your first megabyte:
const big = new Uint8Array(1000000);
btoa(String.fromCharCode.apply(null, big));
// RangeError in Firefox: "too many arguments provided"
// RangeError in Chrome: "Maximum call stack size exceeded"
The fix that has saved more file-upload features than any other single change is to cross the bridge in chunks, a few thousand characters at a time, and join the results:
function bytesToBase64Chunked (bytes) {
const CHUNK = 0x8000;
const parts = [];
for (let i = 0; i < bytes.length; i += CHUNK) {
parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)));
}
return btoa(parts.join(''));
}
Each chunk is small enough to apply safely, subarray gives a view without copying, and the join produces the exact same binary string the loop would have. Now the text side of the coin. For any real text, TextEncoder - the platform's UTF-8 encoder, available in Firefox 18, Chrome 38, Safari 10.1 and everywhere since - turns your string into honest bytes before the bridge does its work:
const bytes = new TextEncoder().encode('hello 你好');
const base64 = bytesToBase64Chunked(bytes);
console.log(base64); // "aGVsbG8g5L2g5aW9"
That output is what "hello 你好" really is on the wire: six ASCII bytes plus six UTF-8 bytes for the two Chinese characters, all wearing the same printable disguise. If your text is not UTF-8 - and on the web, it usually is - you need the other charset first, which means encoding it somewhere that speaks that charset, usually the server. TextEncoder deliberately refuses to guess, and it is right to.
The 2025 Shortcut: Uint8Array.toBase64
If you already hold a Uint8Array, the bridge is a detour, because the 2025 ECMAScript feature encodes the array directly: bytes.toBase64(options). It landed in Chrome 140, Edge 140, Firefox 133, Safari 18.2, Node 25 and Deno 2.5 - the same Baseline 2025 wave as its decoding sibling - and it takes two options that turn it into the most versatile encoder on the platform. The first is alphabet: "base64" (the default) or "base64url". The second is omitPadding: set it to true and the trailing = characters are dropped, which is the shape most URL-friendly consumers want. Passing anything else as options throws a TypeError, which is the API being polite about your typo:
const bytes = new Uint8Array([251, 255]);
console.log(bytes.toBase64()); // "+/8="
console.log(bytes.toBase64({ omitPadding: true })); // "+/8"
console.log(bytes.toBase64({ alphabet: 'base64url' })); // "-_8="
Those two bytes are chosen to be maximally rude to the alphabet: they produce a + and a / in standard mode, so the second line shows exactly what changes when you switch to base64url. Performance is the quiet bonus: on a recent Firefox, encoding ten megabytes takes about five milliseconds with toBase64, while the string-bridge path above takes roughly fifteen times longer, because it builds a giant intermediate string in the process. On older browsers the bridge remains perfectly serviceable for anything under a few megabytes - and the chunked version above is the one you want, for the reasons in the last section.
URL-Safe Output
Base64 has a dedicated variant for the places where +, / and = cause damage, and it is worth its own section because so much broken code is just standard Base64 that met a URL. In a query string, + is a space; in a path, / is a separator; and = wants percent-encoding in some positions. The URL and filename safe alphabet from RFC 4648, section 5 - base64url - swaps those two characters for - and _, and since the data length is usually known on the receiving side, it also permits dropping the padding entirely. The output travels through URLSearchParams, path segments, fragments and filenames without a single percent sign.
With the 2025 API this is one options object:
const params = new URLSearchParams();
params.set('payload', bytes.toBase64({ alphabet: 'base64url', omitPadding: true }));
console.log(params.toString()); // "payload=-_8" - no percent-encoding at all
On older browsers, convert after encoding with btoa. Two replaces and a trim do the whole job:
function toUrlBase64 (base64) {
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
console.log(toUrlBase64(btoa('hi?/x'))); // "aGk_L3g"
Three rules keep the channel clean. Pick one alphabet per channel and stick to it - a value that mixes + and - belongs to neither family, and no decoder will guess which one you meant. Padding is a contract, not a suggestion: if you omit it, the receiver must be ready for an unpadded value, and if you keep it, the receiver must not choke on it (browsers are lenient, some JSON schemas are not). And remember the swap is reversible and lossless - - and _ map to exactly the same 62nd and 63rd alphabet positions that + and / occupy, so nothing is lost by choosing the friendlier pair.
Making Images Travel: Data URLs
The oldest and most visible use of Base64 in the browser is the data URL: data:, an optional media type, an optional ;base64 flag, a comma, then the payload. Text payloads are percent-encoded; binary payloads - images, fonts, audio - are Base64, and the browser renders them with zero HTTP requests. For an image file the user just picked, the FileReader does the encoding for you and hands back the finished URL:
const reader = new FileReader();
reader.onload = () => {
console.log(reader.result); // "data:image/png;base64,iVBORw0KGgo..."
imageElement.src = reader.result;
};
reader.readAsDataURL(file);
The result is a ready-made src, a value you can store in localStorage, or send in a JSON body. If the image is on a canvas instead - a screenshot, a processed photo, a generated chart - canvas.toDataURL() has been doing this job since the earliest browser releases, and it even lets you choose the format and, for lossy formats, the quality:
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.drawImage(photo, 0, 0);
const pngUrl = canvas.toDataURL('image/png');
const jpegUrl = canvas.toDataURL('image/jpeg', 0.8);
Three pitfalls to plan around. First, the tainted canvas rule: if you drew a cross-origin image onto the canvas without CORS permission, every attempt to read pixels back - including toDataURL - throws a SecurityError. The fix is to load the image with crossOrigin = 'anonymous' and make sure the server sends the right headers. Second, the quality argument is ignored for PNG and only means something for JPEG (and WebP) - a common source of "why is my PNG bigger". Third, and the biggest: the payload is about 33 percent larger than the file, and it sits in the page as a string. For images that never leave the browser, there is a free alternative - an object URL, which wraps the Blob without encoding it at all:
const objectUrl = URL.createObjectURL(blob);
imageElement.src = objectUrl;
URL.revokeObjectURL(objectUrl); // once you are done with it
The division of labor that falls out of this: object URLs for anything that stays on the page, data URLs for anything that must be copied, stored, or sent as text. Both are first-class; they are just solving different problems.
Building and Signing a JWT
If you generate tokens in the browser - for a self-hosted auth flow, a demo, or a serverless front end - the compact JWS format is three base64url segments: header, payload, signature, no padding anywhere. The Web Crypto API handles the signing; the encoding is exactly the URL-safe output from two sections ago:
const encoder = new TextEncoder();
const segment = (bytes) =>
bytes.toBase64({ alphabet: 'base64url', omitPadding: true });
const header = segment(encoder.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
const payload = segment(encoder.encode(JSON.stringify({ sub: '1234567890', name: 'John Doe' })));
const key = await crypto.subtle.importKey(
'raw',
encoder.encode('shared-secret'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = segment(
new Uint8Array(
await crypto.subtle.sign('HMAC', key, encoder.encode(header + '.' + payload))
)
);
const token = header + '.' + payload + '.' + signature;
Two details matter more than the plumbing. The signature covers exactly header + '.' + payload - the raw segments, not the JSON - so any edit to either part invalidates the token, which is the whole point of it. And crypto.subtle.sign returns a raw ArrayBuffer, hence the one-line wrap in a Uint8Array before the segment encoder. For RSA-based tokens the flow is identical with RS256 and a key pair, and if you export a public key as a JWK (crypto.subtle.exportKey('jwk', key)), the numeric members - n, e, and for private keys d, p, q - come out as unpadded base64url automatically. The security caveats are the same as for any token: an alg: "none" header is a request to skip verification, time claims (exp, nbf) must be enforced, and a server that accepts both HMAC and RSA for the same audience opens the classic key-confusion door. Encode correctly, sign correctly, verify on the receiving side.
Authentication Headers
The simplest authentication scheme on the web is also the most instructive about what Base64 is and is not. HTTP Basic sends Authorization: Basic followed by the Base64 of username:password - one call, no bytes bridge needed, because usernames and passwords are (hopefully) plain text:
const credentials = btoa('alice:secret123');
fetch('/api/me', {
headers: { Authorization: 'Basic ' + credentials }
});
// Authorization: Basic YWxpY2U6c2VjcmV0MTIz
And here is the lesson that fits on one line: Base64 is not encryption. The header above is one atob call away from alice:secret123 - for the attacker and for anyone reading the logs - so Basic auth is only acceptable over HTTPS, where the transport is the actual protection and Base64 is just the formatting. For anything with a longer life than one request, prefer token-based schemes: a Bearer token is also a single header, but it is a random value whose secret never needs to be carried in the header at all, and it can be revoked. The encoding choice between the two is trivial - both are btoa or plain text - but the security choice is not, and it should be made on purpose.
Files In, Text Out
Uploads are where the 33 percent tax gets quoted in real money, because the file is usually the biggest thing on the page. There are two roads, and the first one is the one you should take by default: multipart form data. FormData carries the file as raw bytes in a standard body, with the browser doing the framing, and there is no Base64 anywhere in the picture - no size tax, no intermediate string, and the bytes stream to the server as they are read:
const form = new FormData();
form.append('upload', file);
await fetch('/api/upload', { method: 'POST', body: form });
The second road is for the APIs that insist on a JSON body with the file as a string - some serverless functions, some mobile backends, some legacy services. There the encoding is one line per file, and the cost is exactly what the tax says it is: a 5 megabyte file becomes a 6.7 megabyte string, which then gets serialized into JSON, which then gets sent. Fine for a photo, painful for a video:
const bytes = new Uint8Array(await file.arrayBuffer());
const body = JSON.stringify({
name: file.name,
content: bytes.toBase64()
});
await fetch('/api/upload-json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
});
For big files on that road, do not build one giant string in a single call - build it in slices, where each slice is a multiple of three bytes. That alignment is what makes the trick legal: a multiple of three bytes encodes to a clean multiple of four characters with no padding, so independently encoded slices concatenate into exactly the encoding of the whole file, and only the final slice ever carries padding:
async function encodeLargeFile (file) {
const bytes = new Uint8Array(await file.arrayBuffer());
const SLICE = 3 * 1000 * 1000;
const parts = [];
for (let i = 0; i < bytes.length; i += SLICE) {
parts.push(bytes.subarray(i, i + SLICE).toBase64());
}
return parts.join('');
}
The same alignment idea is why you should never split a Base64 string at an arbitrary point and expect the pieces to decode on their own - a three-byte group is the atom, and a cut in the middle of one leaves a dangling fragment. Downloads are the mirror image: for a generated file, a small one can go out through a data URL on a download link, but for anything substantial a Blob plus an object URL is the healthy route, because the browser never has to carry the whole payload as a string in the first place.
Storing and Sharing State
Two more text-only channels where Base64 does real work. The first is storage: localStorage and sessionStorage hold strings, so structured or binary data gets encoded before it goes in. The round trip is one encode and one decode, and it is worth seeing both sides together, because a storage bug is almost always a charset mismatch between them:
const state = { theme: 'dark', draft: 'hello' };
const packed = new TextEncoder().encode(JSON.stringify(state));
localStorage.setItem('app-state', new Uint8Array(packed).toBase64());
const raw = atob(localStorage.getItem('app-state'));
const bytes = Uint8Array.from(raw, (c) => c.codePointAt(0));
const state = JSON.parse(new TextDecoder().decode(bytes));
Budget it properly, though: the origin gets roughly 5 megabytes of localStorage, your stored string is 33 percent fatter than the data, and while the page is open the string also lives in memory as UTF-16 - twice its length again. A 3 megabyte asset is 4 megabytes of storage and 8 megabytes of memory, which is how a "small" feature becomes a quota error. The second channel is the URL itself: share links, deep links, and OAuth state all want structured data in a place that survives copy-paste. The recipe is compact state, JSON, then base64url without padding, so the value needs no percent-encoding at all - and keep the whole URL under a couple of thousand characters, which is where older clients, proxies, and logging tools start to get nervous.
Email and MIME
Base64 is older than the web, and its home ground is email. MIME attachments with Content-Transfer-Encoding: base64 are how a binary file rides inside a text protocol, and the convention that followed from the old 76-character line limit of the message format is worth knowing: wrap the encoded body at 76 characters per line. The browser cannot send SMTP, but it does two email jobs - building MIME bodies that a backend relay will send, and displaying the attachments of messages it receives - and both touch the encoding. The wrap itself is a two-line function, and the order of operations matters: encode first, wrap second, because btoa will throw on whitespace in its input:
function wrapForMime (base64, width) {
const w = width || 76;
return base64.match(new RegExp('.{1,' + w + '}', 'g')).join('\r\n');
}
The receiving side is the free one: atob skips ASCII whitespace as part of its standard behavior, so a wrapped MIME body decodes exactly as it arrived, newlines and all, without an unwrap step. If you are building a webmail client or an attachment picker, that single asymmetry - the encoder must produce clean lines, the decoder does not care - is the whole MIME story in one sentence.
When to Reach for a Library
With the native tools above, a library is rarely necessary, and the honest guidance is: default to the platform, and add a package only when a real requirement points at one. The three that actually show up in codebases:
js-base64 (npm install js-base64) is the general-purpose one: a small pure-JavaScript transcoder that treats UTF-8 strings as first-class citizens - Base64.encode on a CJK string does the UTF-8 dance for you - and, usefully for decoding as much as encoding, accepts both alphabets in decode and ships an isValid check. It is the right answer when you target browsers where the 2025 APIs are missing and you want one import to cover strings and bytes:
import { Base64 } from 'js-base64';
const encoded = Base64.encode('小飼弾'); // "5bCP6aO85by+" - UTF-8 handled for you
const decoded = Base64.decode('5bCP6aO85by-'); // reads standard and URL-safe alike
const valid = Base64.isValid(encoded); // true
base64-js is the byte-focused one: fromByteArray and toByteArray on Uint8Arrays, no dependencies, the workhorse of the old browserify ecosystem and still a fine choice when your code lives in typed arrays and you want the encoding to be a pure function of bytes. And if your reason to want a library is "I like the 2025 API but I cannot require 2025 browsers", the answer is not a Base64 package at all but a polyfill: core-js and es-shims both implement Uint8Array.fromBase64 and friends, so you can write the new-style code once and let the shim fill the gap on older engines. Pick by constraint - old browsers, string convenience, or byte purity - not by habit.
Pitfalls That Cost Developers Hours
- Calling
btoaon a string with a character above code point 255. It throws, it does not mangle, and it stops at the first offender. The fix is always the same:TextEncoderfirst, bridge second. - The
fromCharCode.applycliff on big arrays. A million arguments is aRangeErrorin both major engines. Chunk the bridge, or move totoBase64. - Forgetting the size tax where it hurts most: storage. A file in
localStorageis 33 percent bigger than the file, and the quota is per origin, shared with everything else your app saves. - Standard Base64 meeting a query string. The
+arrives as a space, the/breaks the path, and the bug reports say "the API is flaky". URL-safe output, no padding, and the whole genre of bug disappears. - Inconsistent padding across services. One gateway keeps the
=, another strips it, a third adds it back. The receiver must be ready for both shapes, and the contract should say which one is canonical. - Treating Base64 as a lock. It is a serialization format, one function call from plaintext, and "encoded with Base64" in a security review is a finding, not a control.
- Binary strings as a memory model. A decoded or encoded megabyte rides in UTF-16 at two megabytes; a
Uint8Arrayholds it at one. For big payloads, keep the bytes in typed arrays from end to end. - Double encoding. A value that was already Base64 gets encoded again, and the consumer decodes once and gets a string of letters instead of data. When in doubt, check before you wrap - a string that is already in the alphabet with valid padding is a smell.
- Trusting a JWT payload because it decoded cleanly. Decodability is not authenticity. Verify the signature with the right key and the right algorithm before reading a single claim.
Performance: What a Million Bytes Costs
Base64 in the browser is cheap where it used to be expensive, and the budget now has three line items instead of one. CPU: on a recent Firefox, Uint8Array.toBase64 encodes ten megabytes in about five milliseconds, while the chunked btoa bridge takes roughly fifteen times longer - not because btoa is slow, but because the bridge builds a giant intermediate string on the way. If your encoding budget is in milliseconds, use the native method; if you are encoding a 2 kilobyte configuration object, both are below the threshold of perception. Bandwidth: this is the permanent tax - every byte you encode costs 1.33 bytes on the wire, plus whatever framing the transport adds. Measure the transfer before you "optimize" the encoding. Memory: the encoded string is the biggest transient allocation you will make, and for a 5 megabyte file it is a 6.7 megabyte string, or about 13.4 megabytes of UTF-16 memory while the page holds it. The practical consequences fall out of the arithmetic: slice big encodings so no single string gets huge, release the intermediate bytes as soon as the string exists, prefer object URLs and multipart when the bytes never needed to be printable, and move multi-megabyte work to a Web Worker if the main thread must keep scrolling smoothly. The format is four decades old; the platform finally caught up with it.
How Browsers Learned to Encode
The encoder has a history, and it explains the relics you will inherit. btoa - "binary to ASCII", the name is literal, and atob is just the same words reversed - was part of the first HTML5 drafts around 2008, and the engines shipped it early: Firefox from 2004, Safari 3, Chrome 4. Internet Explorer, characteristically, skipped both functions until version 10 in 2011, and that single absence is the reason a decade of JavaScript is full of hand-rolled Base64 tables and one particular incantation for Unicode: btoa(unescape(encodeURIComponent(str))). It worked - encodeURIComponent produces percent-escaped UTF-8, and unescape turned that into a byte string - but it was built on two functions the language had deprecated, and it survived in browser code for years out of pure inertia. The principled fix arrived with the Encoding standard: TextEncoder and TextDecoder, in Firefox 18 (2013), Chrome 38 (2014), Safari 10.1 (2017), and in no version of IE at all - another IE gap, another decade of workarounds. Node.js tells the server-side half of the story: it had Buffer with Base64 from day one, but atob and btoa as globals only from version 16 in 2021, with two small npm shims carrying the load before that. And then, in September 2025, the language itself shipped Base64 - Uint8Array.toBase64 and friends in Chrome 140, Firefox 133, Safari 18.2 and Node 25, marked Baseline 2025 - the same feature set the platform had been approximating with helpers for twenty years, now standard. The fun trivia at the end of the article is mostly about how long each piece took to arrive.
Did You Know?
- The function names are a phrase:
btoais "binary to ASCII" andatobis "ASCII to binary". No one abbreviates the other direction, which is why the pair has been self-documenting since the 2000s. - The most encoded string in the history of computing is probably "hello":
btoa('hello')isaGVsbG8=, the output of every tutorial, test suite, and interview whiteboard on the planet. - Every valid Base64 string has a length that is a multiple of four, padding included. The
=characters are a fingerprint: one of them means the last group held two bytes, two of them means it held one. - The 76-character line wrap in MIME and in most command-line tools is an inheritance from the email era, when the line length of the message format set the limit. The number has survived three decades of faster everything.
- "Data URI" is a retired name. The WHATWG renamed it to "data URL" during the great URI-to-URL harmonization, which is why specs, blog posts, and package names all spell it differently in the same paragraph.
btoa('')returns'': an empty input produces an empty output, no padding, no special case. The only Base64 string with a length that is not a multiple of four is the empty one.- A canvas can turn a photo into a data URL with
toDataURL- a capability that has existed since IE 9, Firefox 2 and Safari 4, predating most of the web platform we think is "modern" - and round-trip it back with an<img>tag and aFileReader. - The WebSocket handshake encodes
SHA-1(key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11)in Base64, and the GUID is a fixed constant in the RFC that was chosen precisely so that no plain HTTP server could ever complete the handshake by accident.
Where to Go From Here
The whole craft of encoding in the browser fits on one page: btoa for the plain, single-byte cases it was born for; TextEncoder plus the chunked bridge for real text and files on any browser; Uint8Array.toBase64 with its alphabet and padding options for the modern, direct path; and the URL-safe variant, with or without padding, for anything that will live in a URL. The rest is judgment: know the 33 percent tax before you spend it, keep the bytes in typed arrays while they are big, encode first and wrap second, and never call a serialization format a lock. When the channel can carry raw bytes, take the bytes - Base64 is for the roads that only admit printable text, and now you know exactly how to pay the toll.
The other half of the journey - receiving one of these strings and pulling the bytes, text, and meaning back out of it - is covered in detail in the companion guide to Base64 decoding in JavaScript, linked below.
Last updated: 2026-08-29
Related article: Base64 Decoding in JavaScript/Browser: A Complete Guide