> ## Content Index
> Fetch the complete content index at: https://www.tool-cloud.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Convert Text to Base64 in JavaScript (and Decode It)
- URL: https://www.tool-cloud.com/blog/javascript-base64-encode/
- Published: 2026-10-14T18:18:34.000Z
- Updated: 2026-10-14T18:28:52.000Z
- Description: 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.
- Author: Tool Cloud Team
- Tags: Developers, Guides

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()](https://developer.mozilla.org/en-US/docs/Web/API/Window/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](https://developer.mozilla.org/en-US/docs/Glossary/Base64) 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…](https://www.tool-cloud.com/blog/content/images/2026/10/javascript-base64-encode-utf8-split.webp)

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](https://web.dev/articles/base64-encoding), `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](https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa), which runs in every browser that supports `TextEncoder` (IE excluded):

```js
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:

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

```

Decoding reverses it:

```js
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](https://github.com/tc39/proposal-arraybuffer-base64/blob/main/README.md) 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global%5FObjects/Uint8Array/toBase64), 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()

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

```

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

### Decoding: Uint8Array.fromBase64()

```js
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](https://tc39.es/proposal-arraybuffer-base64/) 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

```js
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:

```js
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:

```js
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')`.

```js
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()](https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa) Baseline widely available since July 2015, not deprecated. In Node, the [type definitions](https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/58795) 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.

```js
// 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](https://www.tool-cloud.com/dev/base64%5Fencoder/), confirm you get `'SGVsbG8sIHdvcmxkIQ=='`, then decode it back and check it's identical. There's also a standalone [Text to Base64 converter](https://www.tool-cloud.com/general/text%5Fto%5Fbase64/) and [Base64 to Text converter](https://www.tool-cloud.com/general/base64%5Fto%5Ftext/); 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.