Base64 in JavaScript

btoa() encodes only code points 0–255, so it throws on Korean, emoji or any accented character. Convert to UTF-8 bytes first — everything else about Base64 in JavaScript follows from that one fact.

Every snippet and every output below was executed on Node 23 and matches what the browser produces.

The encode path that works

const text = "Hello, 안녕 🎉";

// ✗ throws: InvalidCharacterError
btoa(text);

// ✓ browser
btoa(String.fromCharCode(...new TextEncoder().encode(text)));
// "SGVsbG8sIOyViOuFlSDwn46J"

// ✓ Node — same result, one line
Buffer.from(text, "utf8").toString("base64");
// "SGVsbG8sIOyViOuFlSDwn46J"

The two paths produce byte-identical output, which we checked rather than assumed. The reason the naive call fails is that btoa was specified when JavaScript strings were treated as sequences of bytes; it reads each code unit as one byte and refuses anything above 255. TextEncoder does the missing step, turning text into the UTF-8 bytes that Base64 is defined over.

You will still encounter the older idiom btoa(unescape(encodeURIComponent(text))) in existing code. It genuinely works — we verified it produces the same string — but unescape is deprecated and the round trip through percent-encoding obscures the intent. There is no reason to write it in new code.

When atob() fails, precisely

The decode side has a reputation for being fussy about padding that turns out to be only half right. We measured the actual behaviour:

InputLength mod 4Result
"Y"1throws
"YQ"2"a"
"YWJ"3"ab"
"YWJj"0"abc"
"w7vDv8O-"0throws — - is not in the alphabet

So padding is optional: atob only rejects a length whose remainder is 1, which cannot represent a whole number of bytes. What it will never accept is a character outside the standard alphabet, which is why a base64url string fails on its very first - or _. That distinction matters when you are debugging: a thrown InvalidCharacterError on a token almost always means wrong variant, not wrong padding.

Converting base64url

// decode base64url in the browser
function fromBase64Url(s) {
  s = s.replace(/-/g, "+").replace(/_/g, "/");
  while (s.length % 4) s += "=";
  return new TextDecoder().decode(
    Uint8Array.from(atob(s), c => c.charCodeAt(0))
  );
}

// Node needs none of this
Buffer.from(text, "utf8").toString("base64url");

Node treats base64url as a first-class encoding, and we confirmed its output matches the manual character-swap exactly. The browser has no equivalent, so front-end code still carries the helper above. This is the conversion you need whenever you touch a JSON Web Token, whose three segments are base64url rather than standard Base64.

Data URIs: the size you pay

Embedding an image as data:image/png;base64,… removes a network request, which is why the pattern persists. The costs are less visible. Base64 inflates the payload by about a third — our one-pixel test PNG went from 70 bytes to 96 characters — and the embedded copy shares the lifetime of whatever document contains it, so it cannot be cached independently and every change invalidates the whole file.

For a one-pixel placeholder or a tiny inline icon that trade is fine. For anything a user would notice loading, a separate cacheable file wins, and an SVG usually beats an encoded raster outright.

Frequently asked questions

Why does btoa() throw InvalidCharacterError?

Because btoa() accepts only code points 0 through 255 and treats its argument as a byte string rather than as text. Any Korean, Japanese, Cyrillic or emoji character is above that range, so the call raises InvalidCharacterError instead of encoding — we confirmed btoa("Hello, 안녕 🎉") throws in both Node and the browser. The function dates from before Unicode was ubiquitous on the web and was never updated, because changing it would break existing pages. The fix is to convert the string to UTF-8 bytes first with new TextEncoder().encode(text) and pass those bytes through, which is what every correct implementation does today. The name is a clue to the era: btoa stands for binary-to-ASCII, and it predates the idea that a JavaScript string might hold anything else.

How do I Base64 encode a string with emoji or Korean text?

Encode to UTF-8 bytes first, then Base64 those bytes. In the browser that is btoa(String.fromCharCode(...new TextEncoder().encode(text))); in Node it is the much shorter Buffer.from(text, "utf8").toString("base64"). We verified both produce the identical string for "Hello, 안녕 🎉": SGVsbG8sIOyViOuFlSDwn46J. You may still see the older idiom btoa(unescape(encodeURIComponent(text))), which does work — we checked it produces the same output — but unescape is deprecated and the TextEncoder version says what it means. For very large strings, spreading the byte array into fromCharCode can exceed the argument limit, so chunk it in blocks of a few thousand bytes. Note that the reverse direction has no such restriction, because atob returns a string of single-byte characters that TextDecoder then interprets.

When does atob() actually fail?

Less often than people assume, and the exact rule is worth knowing. atob() throws InvalidCharacterError on any character outside the standard alphabet, which is why a base64url string containing - or _ fails immediately. Padding, however, is optional: we measured that atob decodes an unpadded string whose length leaves a remainder of 2 or 3 when divided by four, and only throws when the remainder is 1. So atob("YQ") returns "a" while atob("Y") throws. Do not lean on that tolerance — restore the padding when converting from base64url, because other languages and older engines are stricter. Whitespace is also tolerated inside the input, which is why a Base64 blob copied out of a wrapped email header often decodes without complaint.

How do I convert base64url in JavaScript?

Swap the two differing characters and fix the padding. Decoding requires replacing - with + and _ with /, then appending = until the length is a multiple of four before calling atob. Encoding is the reverse: run btoa, then replace + with -, / with _, and strip trailing equals signs. Node makes this unnecessary because Buffer supports base64url as a native encoding — Buffer.from(text, "utf8").toString("base64url") does the whole thing, and we confirmed it matches the manual transformation exactly. The browser has no equivalent shortcut, so the four-line helper is still the standard approach in front-end code. You need it any time you touch a JSON Web Token, whose header, payload and signature are all base64url so that the token survives being placed in a URL or an HTTP header.

Should I Base64 encode images as data URIs?

Only for genuinely tiny assets. A data URI embeds the image directly in HTML or CSS, saving a request, but Base64 costs roughly a third in size and the embedded copy cannot be cached separately from the document that contains it. Our test PNG measured 70 bytes raw and 96 characters encoded. That means every page view re-downloads the image along with the markup, and a change to the image invalidates the whole file. Small icons and one-pixel placeholders are reasonable; anything a user would notice loading belongs in its own cacheable file, ideally SVG, which is usually smaller than an encoded raster and scales without a second asset. As a rough line, assets under about a kilobyte are worth inlining and everything larger is not.

References

Try it without writing code with the Base64 encoder and decoder. More browser-only tools at withuse.io/tools.