Tools

Image Video Audio Text Vector Developer General

⚡ 20 free credits left today

Sign up free

How to Convert Text to Base64 in JavaScript (and Decode It)

The one-liner that works, the one that throws, and why it throws — plus the UTF-8-safe round trip, the 2025 native methods and Node.js differences.

Soft 3D illustration of layered blue document shapes transforming into a grid of encoded blue cubes that dissolve into…

To base64-encode a string in JavaScript, call btoa('Hello, world!'), which returns 'SGVsbG8sIHdvcmxkIQ=='. That one-liner only works for ASCII and Latin-1 text, though: the moment you pass an emoji, an accent or a Chinese character, it throws InvalidCharacterError. This guide shows why that happens and the UTF-8-safe round trip that fixes it, including the new native methods in 2025.

Base64 in one line: btoa() and atob()

The fastest answer: encode with btoa('Hello, world!'), decode with atob('SGVsbG8sIHdvcmxkIQ=='). Both verified: btoa('a') returns 'YQ==', and atob('SA==') returns 'S'.

What btoa() actually does is treat your string as a "binary string", a string where each character stands in for exactly one byte of binary data, then Base64-encodes those bytes. Its MDN use case is data that causes transmission problems, like ASCII control characters (0–31), that you want to smuggle through a text-only channel.

This one-liner is enough when your text is ASCII: every character's code point must be below 256, and per MDN the byte-to-code-point correspondence only reliably holds up to 0x7F. The range 0x80–0xFF technically works, but it relies on Latin-1 encoding behaviour that isn't portable across environments. A good rule: anything non-ASCII needs the UTF-8 path below.

Why btoa() throws InvalidCharacterError on emoji, accents and CJK

Base64 is a binary encoding, not a text encoding. MDN's glossary calls the habit of passing arbitrary text or HTML to btoa() a common misconception: btoa/atob predate typed arrays, so they use strings to stand in for bytes. The trouble starts when your string isn't one byte per character.

JavaScript strings are UTF-16, so a code unit is always two bytes, and characters outside the BMP (like emoji) take four bytes as a surrogate pair; UTF-8 uses one to four bytes per code point. btoa() and atob() only work with single-byte (Latin-1 range) characters. Try to encode anything wider and you get this exact error:

DOMException: Failed to execute 'btoa' on 'Window':
The string to be encoded contains characters outside of the Latin1 range

The breaking case is trivial to reproduce: btoa('✓') throws, and so does btoa('a Ā 𐀀 文 🦄'). Emoji, accented letters beyond Latin-1, and CJK characters all sit above 0xFF, so none of them fit the one-byte-per-character assumption. '✓' is U+2713, well above 0xFF, so btoa() refuses rather than silently corrupt your data. That refusal is arguably the API's best feature: it fails loudly instead of producing mojibake you'd discover weeks later.

One more thing worth knowing before we fix this: Base64 makes data bigger, by roughly 33% compared to the raw bytes. That's fine for short strings, tokens and data URIs, but a real reason not to Base64 large payloads — encode at the boundary you need, not everywhere.

A soft 3D blue ribbon flowing in from the left and splitting into two paths: one strand forms neat clustered groups of…
One stream of data, two byte footprints: multi-byte clusters versus single bytes.

The lone-surrogate edge case (and String.isWellFormed())

Most articles skip this, but it bites: a UTF-16 string containing a lone surrogate (half of an emoji pair, usually from a slicing bug) is "malformed". Per web.dev's guide, TextDecoder silently substitutes U+FFFD by default. Before encoding, check well-formedness with String.prototype.isWellFormed(), or catch the URIError that encodeURIComponent() throws on lone surrogates, then decide explicitly: reject the data, enable replacement deliberately, or throw for later analysis.

The correct way: TextEncoder → UTF-8 bytes → Base64

The correct fix is to convert your string to UTF-8 bytes with TextEncoder first, then Base64-encode the bytes instead of the string. new TextEncoder().encode(str) turns any JavaScript string into a Uint8Array of UTF-8 bytes (TextEncoder only speaks UTF-8). On the way back, new TextDecoder() defaults to UTF-8 and reverses it exactly. Note what atob() gives you here: a binary string, one character per byte, not UTF-8 text. That's why the decode side needs TextDecoder to turn bytes back into readable text.

bytesToBase64() and base64ToBytes() helpers (copy-paste code)

Here's MDN's canonical round trip, which runs in every browser that supports TextEncoder (IE excluded):

function bytesToBase64(bytes) {
  const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join('');
  return btoa(binString);
}

function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

Verify with a full round trip

Encoding a string becomes one line:

const encoded = bytesToBase64(new TextEncoder().encode('a Ā 𐀀 文 🦄'));
// 'YSDEgCDwkICAIOaWhyDwn6aE'

Decoding reverses it:

const decoded = new TextDecoder().decode(base64ToBytes(encoded));
// 'a Ā 𐀀 文 🦄'

web.dev's verified round trip confirms the same pattern: 'hello⛳❤️🧀' encodes to 'aGVsbG/im7PinaTvuI/wn6eA' and decodes back exactly, emoji included.

Skip the unescape(encodeURIComponent()) trick

You'll find btoa(unescape(encodeURIComponent(str))) copied everywhere. It works, but unescape() is Annex B legacy, deprecated since ECMAScript 3. New code should use TextEncoder instead: it's explicit, standard, and doesn't depend on a deprecated global.

The 2025 way: Uint8Array.toBase64() and Uint8Array.fromBase64()

Since September 2025, JavaScript has native Base64 methods on Uint8Array themselves, so the byte-string helpers above are no longer the only option. The TC39 Base64 proposal reached Stage 4 and is part of the language; TC39's rationale is that base64 is not a text encoding, so TextEncoder/TextDecoder are conceptually the wrong tool for it.

Per MDN, these are Baseline "Newly available" across evergreen browsers. Check your target runtimes before shipping: if you support older browsers or older Node versions, keep the TextEncoder + btoa fallback. MDN prefers these methods over btoa()/atob() when you already have a Uint8Array, because no raw-byte string has to be created.

Encoding: bytes.toBase64()

const b64 = new TextEncoder().encode('Hello, world!').toBase64();
// 'SGVsbG8sIHdvcmxkIQ=='

One UTF-8 conversion, one method call, done.

Decoding: Uint8Array.fromBase64()

const text = new TextDecoder().decode(Uint8Array.fromBase64(b64));
// 'Hello, world!'

MDN notes fromBase64() should be preferred over atob() because it returns a real byte array, unless the decoded data is intended as ASCII text. It accepts only the base64 alphabet (A–Z, a–z, 0–9, plus +/ or -_), optional = padding, and ignores ASCII whitespace; it throws SyntaxError on characters outside the alphabet.

Options: alphabet, omitPadding, lastChunkHandling

The spec's options bag gives you precise control:

Option Values What it does
alphabet 'base64' (default), 'base64url' +// vs -/_
omitPadding boolean Drops trailing =: new Uint8Array([72]).toBase64() → 'SA==', with omitPadding → 'SA'
lastChunkHandling 'loose' (default), 'strict', 'stop-before-partial' Decoder tolerance for the final chunk

'loose' tolerates missing padding and whitespace. 'strict' requires exact 4-character chunks with zeroed overflow bits, which is what you want when validating untrusted input. 'stop-before-partial' is for streaming. The default loose mode also tolerates non-zero overflow bits, which strict mode rejects.

Base64 vs base64url: which alphabet do you need?

Standard base64 uses + and / plus = padding, and it's fine for data: URLs because those have no path segments or query strings. base64url (RFC 4648 §5) swaps in - and _ and usually omits padding, and it's required whenever the output travels inside a URL: JWTs use base64url for all three of their segments, for example.

When to use base64url

Use base64url when the encoded string becomes part of a URL path or query parameter, where + and / would need escaping (and = padding clutters query strings). Use standard base64 for data URIs, embedding bytes in JSON payloads, or anything that isn't a URL.

How to produce base64url

Three ways, depending on environment:

  • Modern browsers: bytes.toBase64({ alphabet: 'base64url' })
  • Node 16+: Buffer.from(str).toString('base64url')
  • Post-processing: take standard output and replace + → -, / → _, then strip the =

One subtlety worth knowing: RFC 4648 requires encoders to zero the non-data overflow bits in the final chunk, and strict decoders may throw if they aren't zeroed. MDN's glossary gives the example of 'VR==', which is the same byte as 'VQ==' with dirty padding bits. Generate base64 with a well-behaved encoder and this never comes up; hand-roll one and it might.

Encoding a file or image to Base64 (data URLs)

For files, the browser route is FileReader with readAsDataURL(), which produces a full data URL, then you strip the prefix to get the raw base64. For images already rendered in a canvas, toDataURL('image/png') does it in one call.

FileReader.readAsDataURL() → data URL → raw base64

function fileToDataURL(file) {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result);
    reader.readAsDataURL(file);
  });
}

const dataUrl = await fileToDataURL(fileInput.files[0]);
const rawBase64 = dataUrl.replace(/^data:\w+\/\w+;base64,/, '');

canvas.toDataURL() for images

If you've drawn an image onto a canvas (resize, crop, whatever), skip FileReader entirely:

const dataUrl = canvas.toDataURL('image/png');

Same prefix-stripping applies if you need the bare base64 string.

Data URL back to bytes

The reverse direction is a fetch plus an array buffer. MDN's example: a data URL built from new Uint8Array([0,1,2]) is 'data:application/octet-stream;base64,AAEC', and you recover the bytes like this:

const res = await fetch(dataUrl);
const bytes = new Uint8Array(await res.arrayBuffer());
// [0, 1, 2]

And the reminder this whole article exists for: don't call btoa() on arbitrary text or HTML to build data: URLs. It's a byte-to-byte encoder, and non-Latin-1 input will throw.

Node.js: Buffer.from(str, 'base64') — and its one dangerous difference

Node has its own idioms that skip the TextEncoder dance entirely: Buffer.from('Hello, world! 🌍').toString('base64') handles Unicode automatically, because Buffer works on bytes from the start. Decode with Buffer.from(encoded, 'base64').toString('utf8').

Other useful forms: Buffer.from(str).toString('base64url') (Node 16+), and whole files via fs.readFileSync('image.png').toString('base64').

const encoded = Buffer.from('Hello, world! 🌍').toString('base64');
const decoded = Buffer.from(encoded, 'base64').toString('utf8');

Node doesn't validate your base64 (silent failures)

Here's the part that surprises people: Node's base64 decoder does not validate input. Buffer.from('!!!!', 'base64').toString() returns '' with no error, and Buffer.from('aGVsbG8!!!', 'base64') silently discards the junk and returns 'hello'. Compare that to atob('aGVsbG8!!!'), which throws InvalidCharacterError. If the base64 comes from users, an API or a third party, validate it before decoding in Node, or use Uint8Array.fromBase64() with lastChunkHandling: 'strict' where the runtimes support it.

Are btoa/atob deprecated? Browsers vs Node

This is genuinely conflicting, so here's the honest resolution. In browsers, MDN marks btoa() and atob() Baseline widely available since July 2015, not deprecated. In Node, the type definitions mark them @deprecated with the comment that they "should never be used in new code", recommending Buffer.from(str, 'base64') instead.

A good rule: it's a legacy API, not a removed one. Fine in browser code, prefer Buffer in Node-specific code. By community consensus (not a spec guarantee), btoa/atob are also the practical cross-environment choice if you need one code path that runs unchanged in browsers, Deno and Node without dependencies.

What to actually use

If you take one recommendation from this article, it's the three one-liners below. Keep btoa/atob for plain ASCII and legacy codebases, and use the bytesToBase64 helper as your browser fallback until toBase64() covers your whole audience.

// Modern browsers (2025+)
const enc = new TextEncoder().encode(str).toBase64();
const dec = new TextDecoder().decode(Uint8Array.fromBase64(b64));

// Browser fallback (MDN helper)
const encFallback = bytesToBase64(new TextEncoder().encode(str));

// Node (validate input first if it isn't yours)
const encNode = Buffer.from(str).toString('base64');

Before shipping, verify your output with a quick round trip in a browser tool. Paste 'Hello, world!' into Tool Cloud's free Base64 Encoder / Decoder, confirm you get 'SGVsbG8sIHdvcmxkIQ==', then decode it back and check it's identical. There's also a standalone Text to Base64 converter and Base64 to Text converter; files are deleted automatically and nothing needs installing.

Frequently asked questions

Why does btoa() fail on emoji but work on English text?

btoa() treats every character as a single byte and requires each code point to be below 256 (reliably only up to 0x7F). Emoji, accented characters and CJK text need multiple bytes, so they trigger a DOMException: InvalidCharacterError. Convert to UTF-8 bytes with TextEncoder first, then encode the bytes.

Is btoa() deprecated?

Not in browsers — MDN marks btoa() and atob() Baseline widely available since July 2015. However, Node's type definitions mark them deprecated in favour of Buffer.from(str, 'base64') in Node-specific code. They're a legacy API, not a removed one.

How do I Base64-encode Unicode text (accents, Chinese, emoji)?

Use new TextEncoder().encode(str) to get UTF-8 bytes, then encode the bytes (btoa() via a byte-string helper, or Uint8Array.toBase64() in modern browsers). To decode, use Uint8Array.fromBase64() or atob() to get bytes back, then new TextDecoder().decode(bytes).

What's the difference between base64 and base64url?

base64url (RFC 4648 §5) uses '-' and '_' instead of '+' and '/', and usually omits the '=' padding. Use base64url when the output goes into URLs or JWTs; standard base64 is fine for data: URLs, which have no path segments or query strings.

Why doesn't Node.js throw an error on invalid base64?

Node's Buffer.from(str, 'base64') does not validate input: it silently ignores invalid characters, so Buffer.from('aGVsbG8!!!', 'base64').toString() returns 'hello' with no error. In browsers, atob() throws InvalidCharacterError on the same input. Validate untrusted base64 before decoding in Node.