completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Text encoding & Base64

Convert JavaScript strings to UTF-8 bytes, Base64, base64url, and hex with TextEncoder, TextDecoder, atob, btoa, and typed arrays.

By the end, you can
  • 01
    Turn strings into bytes and backUse TextEncoder and TextDecoder, and remember that a JavaScript string is a list of UTF-16 code units, not bytes.
  • 02
    Read bytes written as textExplain why UTF-8 uses 1 to 4 bytes, how Base64 padding and base64url work, and how hex writes each byte.
  • 03
    Pick the right toolAvoid the btoa trap, decode data that arrives in chunks, use the new Uint8Array methods safely, and handle real web formats.

Strings are not bytes

A byte is a small number from 0 to 255. Files, network messages, images, and hashes are all lists of bytes. A JavaScript string is something else. Inside, JavaScript keeps a string as a list of UTF-16 code units: numbers from 0 to 65535. So before you can save or send text as bytes, you must convert it.

To hold bytes in JavaScript, you use a typed array, usually a Uint8Array. Each item in a Uint8Array is one byte.

Definition

Encoding turns a string into bytes, using a named rule. The usual rule is UTF-8. Decoding turns bytes back into a string.

Base64 and hex are different. They don't turn text into bytes. They write bytes down as plain text, so you can print them or copy them.

The string "é" is encoded into two UTF-8 bytes, 195 and 169. Decoding those two bytes gives the string "é" again."é"encode195169decode"é"stringUTF-8 bytesstring
Encoding turns the string "é" into the bytes 195 and 169. Decoding turns those bytes back into the same string.
Real-life analogyA message, a shipping label, and a scanner

You write a message and post a package. The sorting machine can't read your handwriting. It needs a label printed in one exact format. Encoding prints that label. At the other end, a scanner reads the label back. That is decoding.

A tracking number is a short code you can copy into an email. Base64 and hex are like tracking numbers. They are easy to copy as text, and they still stand for the same package.

In real life: The message you wrote
In JavaScript: The JavaScript string
In real life: The exact label format
In JavaScript: The encoding, such as UTF-8
In real life: The scanner that reads the label
In JavaScript: The decoder, using the same format
In real life: A tracking number you can copy
In JavaScript: Base64 or hex text that stands for the bytes

The same text can be held in four forms. Each row below is one form. Read across to see what it is made of and where you meet it.

Four ways to hold the same text
FormWhat it is made ofWhere you meet it
JavaScript stringUTF-16 code units: numbers from 0 to 65535.In your code. It is good for working with text, but it is not a list of bytes.
UTF-8 bytes1 to 4 bytes for each code point.In files, fetch bodies, databases, and most web formats.
Base64Text made of 64 safe characters.In places that only accept text, like JSON and URLs. It often ends with = padding.
HexTwo characters for each byte.In hashes, byte dumps, colors, and debugging, because it is easy to read.

If code points are new to you, read Unicode & strings first. If byte arrays are new, read ArrayBuffer & typed arrays. Later, the Files & blobs lesson uses the same conversions to read and write files.

UTF-8 writes each code point as 1 to 4 bytes

STEP THROUGH

Unicode gives every character a number, called its code point. A code point is written like U+00E9, which is the code point of é. UTF-8 is the most common way to write code points as bytes, and the web uses it by default. It works like this:

  • Code points up to U+007F use 1 byte. These are the plain English letters, digits, and symbols, called ASCII.
  • Code points up to U+07FF use 2 bytes, like é.
  • Code points up to U+FFFF use 3 bytes, like €.
  • Bigger code points use 4 bytes, like most emoji.
Four characters and their UTF-8 bytes in hex. A is 41, 1 byte. é is c3 a9, 2 bytes. € is e2 82 ac, 3 bytes. The emoji is f0 9f 98 80, 4 bytes.characterUTF-8 bytes (hex)A411 byteéc3a92 bytes€e282ac3 bytes😀f09f98804 bytes
Bigger code points need more bytes. A takes 1 byte, é takes 2, € takes 3, and the emoji takes 4.

The first byte of each character starts with a fixed pattern of bits. That pattern says how many more bytes belong to the same character. Those extra bytes are called continuation bytes, and they start with their own pattern. This is how a decoder can read a long list of bytes with no gaps between characters. The cards below show each character, its code point, and its bytes in hex.

AU+00411 byte41
éU+00E92 bytesc3 a9
€U+20AC3 bytese2 82 ac
😀U+1F6004 bytesf0 9f 98 80

The next step-through builds a small UTF-8 encoder by hand. It checks that the encoder gives the same bytes as the built-in TextEncoder for "Aé€😀". You won't use this encoder in real code. It is here so you can watch each character pick its byte pattern.

Step through a UTF-8 encoder
Step 0 of 9Ready
Your turn: follow the blue line

Step through a small UTF-8 encoder. Watch each character pick its number of bytes, the same way TextEncoder does.

Running in
  1. script
Next: line 1
  if (codePoint <= 0x7F) return [codePoint];  if (codePoint <= 0x7FF) {    return [0xC0 | (codePoint >> 6),            0x80 | (codePoint & 0x3F)];  }  if (codePoint <= 0xFFFF) {    return [0xE0 | (codePoint >> 12),            0x80 | ((codePoint >> 6) & 0x3F),            0x80 | (codePoint & 0x3F)];  }  return [0xF0 | (codePoint >> 18),          0x80 | ((codePoint >> 12) & 0x3F),          0x80 | ((codePoint >> 6) & 0x3F),          0x80 | (codePoint & 0x3F)];} function encodeUtf8(text) {  const bytes = [];  for (const character of text) {    const codePoint = character.codePointAt(0);    bytes.push(...encodeCodePoint(codePoint));  }  return new Uint8Array(bytes);} const text = "Aé€😀";console.log([...encodeUtf8(text)].join(","));console.log([...new TextEncoder().encode(text)].join(","));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Look at the emoji. JavaScript keeps it as two UTF-16 code units, so "😀".length is 2. A for...of loop goes through code points, so it sees the emoji once. UTF-8 writes it as 4 bytes. So the number of code units, the number of code points, and the number of bytes can all be different.

Count code units, code points, and bytesPop out in the code editor (opens in a new tab)JavaScript
const text = "é😀";
console.log(text.length);
console.log([...text].length);
console.log(new TextEncoder().encode(text).length);

In plain words, line by line:

  1. Line 1 makes a string with two characters, é and an emoji.
  2. Line 2 prints 3. é is one code unit and the emoji is two.
  3. Line 3 prints 2. Spreading a string with ... splits it into code points.
  4. Line 4 prints 6. UTF-8 uses 2 bytes for é and 4 bytes for the emoji.
Look at one character
Character€
Code pointU+20AC
Bytes226, 130, 172
Hexe2 82 ac
Try it yourself

€ is U+20AC. In UTF-8 its bytes are e2 82 ac (hex).

This uses the same hand-made encoder as the step-through above.

Use TextEncoder and TextDecoder for real work

REAL OUTPUT

TextEncoder is built into browsers and Node. It does one job: it turns a string into UTF-8 bytes. You can't pick another rule; it always uses UTF-8. It has two methods:

  • encode(text) returns a new Uint8Array that holds the bytes.
  • encodeInto(text, destination) writes the bytes into a Uint8Array you already have. It returns an object with two numbers: read, how many code units it used, and written, how many bytes it wrote.
TextEncoder and encodeIntoPop out in the code editor (opens in a new tab)JavaScript
const encoder = new TextEncoder();const bytes = encoder.encode("éuro");console.log([...bytes].join(",")); const destination = new Uint8Array(5);const result = encoder.encodeInto("éuro", destination);console.log(result.read + " code units");console.log(result.written + " bytes");console.log([...destination].join(","));

In plain words:

  1. Line 3 prints 195,169,117,114,111. That is 2 bytes for é, then 1 byte each for u, r, and o.
  2. Line 5 makes an empty Uint8Array with room for 5 bytes.
  3. Line 6 writes the bytes for "éuro" into it.
  4. Lines 7 and 8 print 4 code units and 5 bytes. The 4 code units of the string needed 5 bytes.
  5. Line 9 prints the same 5 bytes, now inside the destination.

encodeInto never writes half a character. In the next example, the destination has room for only 1 byte, but é needs 2. So it writes nothing and stops. When that happens, make the destination bigger, or call encodeInto again for the text that wasn't read.

A buffer too small for éPop out in the code editor (opens in a new tab)JavaScript
const destination = new Uint8Array(1);
const result = new TextEncoder().encodeInto("éA", destination);
console.log(result.read, result.written);
console.log([...destination].join(","));

Line 3 prints 0 0: nothing was read and nothing was written. Line 4 prints 0, because the one byte in the destination is still zero.

The table below sums up the two tools and two options you will use most. Each row is one tool or option. Read across to see what it converts and what to remember.

TextEncoder and TextDecoder at a glance
Tool or optionWhat it convertsWhat to remember
TextEncoderA string into bytes.It always uses UTF-8. Use encodeInto when you already have a Uint8Array to write into.
TextDecoderBytes into a string.Give it a label that matches the bytes, such as utf-8, utf-16le, or windows-1252.
fatal: trueBroken bytes into an error.It throws a TypeError instead of writing � (U+FFFD).
stream: trueBytes that arrive in chunks.It keeps an unfinished character until the next chunk arrives.

TextDecoder goes the other way: bytes to a string. You give it a label: the name of the rule the bytes were written with. Examples are "utf-8", "utf-16le", and "windows-1252", an older rule used by many Windows files.

The label must match how the bytes were written. The same byte can mean different things under different rules. For example, the byte 0xE9 is a whole é in Windows-1252. In UTF-8, it is not a full character on its own.

Labels and the byte order markPop out in the code editor (opens in a new tab)JavaScript
const win1252 = new Uint8Array([0x63, 0x61, 0x66, 0xE9]);console.log(new TextDecoder("windows-1252").decode(win1252));console.log(new TextDecoder("utf-8").decode(win1252)); const utf16le = new Uint8Array([0x48, 0x00, 0x69, 0x00]);console.log(new TextDecoder("utf-16le").decode(utf16le)); const withBom = new Uint8Array([0xEF, 0xBB, 0xBF, 0x4F, 0x4B]);console.log(new TextDecoder("utf-8").decode(withBom));console.log(new TextDecoder("utf-8", { ignoreBOM: true }).decode(withBom).charCodeAt(0).toString(16));

In plain words:

  1. Line 2 reads the bytes as Windows-1252 and prints café.
  2. Line 3 reads the same bytes as UTF-8 and prints caf�. The lone 0xE9 is not valid UTF-8.
  3. Line 6 reads UTF-16 bytes, two bytes for each letter, and prints Hi.
  4. Line 8 starts with a byte order mark (BOM): the three bytes EF BB BF. Some files put them at the very start to say "this is UTF-8".
  5. Line 9 prints OK. By default, TextDecoder removes the BOM.
  6. Line 10 uses ignoreBOM: true, so the BOM stays as the character U+FEFF. It prints feff.

What happens when the bytes are broken? There are two choices:

  • By default, the decoder writes the replacement character � (code point U+FFFD) in place of the bad bytes, and keeps going.
  • With { fatal: true }, the decoder throws an error instead. This is safer for files and network formats, where broken data should stop the work.
Replacement versus fatal decodingPop out in the code editor (opens in a new tab)JavaScript
const bad = new Uint8Array([0xC3, 0x28]);console.log(new TextDecoder("utf-8").decode(bad));console.log(new TextDecoder("utf-8", { fatal: true }).decode(bad));

Line 2 prints �(: the bad byte C3 became �, and ( stayed. Line 3 throws a TypeError on purpose.

If broken input can happen, catch the error. In the next example, line 3 throws because C3 28 is not valid UTF-8. Line 5 prints TypeError. Now your code knows the data was broken, instead of using changed text without knowing.

Handle broken UTF-8Pop out in the code editor (opens in a new tab)JavaScript
const bytes = new Uint8Array([0xC3, 0x28]);
try {
  new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (error) {
  console.log(error.name);
}

Big data often arrives in pieces, called chunks. A chunk can end in the middle of a character that needs more than one byte. Here is how to handle that:

  • Pass { stream: true } for every chunk except the last. The decoder keeps the unfinished bytes and waits for the rest.
  • After the last chunk, call decode() without stream: true. This is called a flush: it tells the decoder the data is finished.
  • If a character is still unfinished at the flush, fatal mode throws, and the default mode writes �.
First chunk: bytes 63 61 66 c3. Decoding it with stream: true gives "caf", and c3 waits in the decoder. Second chunk: byte a9. Decoding it gives "é", because c3 and a9 together make é.decoder.decode(first, { stream: true })636166c3"caf"c3 waitsdecoder.decode(second)a9"é"c3 + a9 = é
The first chunk ends with c3, half of é. With { stream: true }, the decoder keeps c3 and gives "é" when a9 arrives.
Decode bytes that arrive in chunks
Step 0 of 7Ready
Your turn: follow the blue line

A decoder for chunks keeps a half-finished character until the next chunk arrives. Step through an é cut in two.

Running in
  1. script
Next: line 1
const first = bytes.slice(0, 4);const second = bytes.slice(4); console.log(new TextDecoder("utf-8").decode(first)); const decoder = new TextDecoder("utf-8", { fatal: true });console.log(decoder.decode(first, { stream: true }));console.log(decoder.decode(second));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Flush an unfinished streamPop out in the code editor (opens in a new tab)JavaScript
const decoder = new TextDecoder("utf-8", { fatal: true });
console.log(JSON.stringify(decoder.decode(new Uint8Array([0xC3]), { stream: true })));
try {
  decoder.decode();
} catch (error) {
  console.log(error.name);
}

Line 2 prints an empty string, "". The decoder is waiting for the second byte of é. Line 4 flushes, but no more bytes came, so fatal mode throws. Line 6 prints TypeError.

Base64 writes bytes as plain text

EDGE CASES

Base64 writes any bytes using only 64 safe characters: A-Z, a-z, 0-9, +, and /. It works in groups:

  • It takes 3 bytes at a time. That is 24 bits.
  • It cuts those 24 bits into four pieces of 6 bits. Each piece is a number from 0 to 63.
  • Each number picks one of the 64 characters. So 3 bytes become 4 characters.
  • If the last group has only 1 or 2 bytes, Base64 adds = at the end to fill the 4 characters. This is called padding.

Base64url is a version made for URLs. It uses - instead of +, and _ instead of /. It often leaves out the = padding.

Browsers have two old functions for Base64. btoa turns a string into Base64, and atob turns Base64 back into a string. They don't take just any text. They take a binary string: a string where each character stands for one byte. So every character code must be from 0 to 255. That is why this first example works.

btoa and atob use binary stringsPop out in the code editor (opens in a new tab)JavaScript
console.log(btoa("é"));const binary = atob("6Q==");console.log(binary.charCodeAt(0));

Line 1 prints 6Q==. The character code of é is 233, which fits in one byte. So btoa treats é as the single byte 233. That is not the UTF-8 form of é. Line 3 reads the byte back and prints 233.

Now compare the two ways side by side. Line 2 below prints 6Q==, like before. Line 4 prints w6k=, because TextEncoder first turns é into the two UTF-8 bytes 195 and 169.

One byte versus UTF-8 bytesPop out in the code editor (opens in a new tab)JavaScript
const text = "é";
console.log(btoa(text));
const bytes = new TextEncoder().encode(text);
console.log(btoa(String.fromCharCode(...bytes)));
Top: btoa(text) reads "é" as one byte, its character code 233, and gives 6Q==. Bottom: TextEncoder first turns "é" into the UTF-8 bytes 195 and 169, and btoa of those gives w6k=.btoa(text)"é"233char code6Q==btoa(String.fromCharCode(...bytes))"é"195169UTF-8 bytesw6k=
btoa alone reads é as the single byte 233. Encoding with TextEncoder first gives the UTF-8 bytes 195 and 169, so the Base64 is different.

The euro sign € has character code 8364. That doesn't fit in one byte, so btoa("€") throws an InvalidCharacterError. Don't try to guess another rule. Turn the text into UTF-8 bytes first.

btoa fails on €Pop out in the code editor (opens in a new tab)JavaScript
console.log(btoa("€"));
Any text to Base64, with TextEncoderPop out in the code editor (opens in a new tab)JavaScript
function bytesToBinaryString(bytes) {  return String.fromCharCode(...bytes);}function binaryStringToBytes(binary) {  return Uint8Array.from(binary, (character) => character.charCodeAt(0));} const bytes = new TextEncoder().encode("€");const base64 = btoa(bytesToBinaryString(bytes));console.log([...bytes].join(","));console.log(base64);console.log(new TextDecoder().decode(binaryStringToBytes(atob(base64))));

In plain words:

  1. Lines 1 to 3 make a helper that turns bytes into a binary string.
  2. Lines 4 to 6 make a helper that turns a binary string back into bytes.
  3. Line 8 encodes € into UTF-8 bytes.
  4. Line 9 turns those bytes into a binary string and passes it to btoa.
  5. Lines 10 and 11 print the bytes, 226,130,172, and the Base64, 4oKs.
  6. Line 12 goes all the way back: atob, then bytes, then TextDecoder. It prints €.

This way is fine for short text. For big data, String.fromCharCode(...bytes) can fail, because one function call can only take so many arguments. For big data, use the new Uint8Array methods in the next section when they exist, or convert the bytes in smaller pieces.

Base64 is not a secret

Anyone can turn Base64 back into the original bytes. Use it only to carry bytes through places that accept text, such as JSON, URLs, or data URLs. Never use it to hide API keys, tokens, or user data.

New Uint8Array methods skip the binary string

FEATURE DETECT

Newer JavaScript adds Base64 and hex methods straight onto Uint8Array. You give bytes and get text, or give text and get bytes. There is no binary string in the middle:

  • bytes.toBase64() turns bytes into Base64, and Uint8Array.fromBase64(text) turns Base64 back into bytes.
  • bytes.toHex() turns bytes into hex, and Uint8Array.fromHex(text) turns hex back into bytes.

MDN lists these methods as standard and "Baseline 2025". They work in Chrome 140, Firefox 133, Safari 18.2, and Node 25. This site's tests run on Node 22, which doesn't have them. So the examples feature-detect: they check that a method exists before they call it, and use another way if it doesn't.

Each row below is one tool. Read across to see what it converts and where it works.

Old and new Base64 and hex tools
ToolWhat it convertsWhere it works
btoa / atobA binary string to Base64, and back.Old, and works everywhere. Every character must fit in one byte (0 to 255).
bytes.toBase64()Bytes to Base64.New and standard. MDN marks it Baseline 2025: Chrome 140, Firefox 133, Safari 18.2, Node 25.
Uint8Array.fromBase64()Base64 to bytes.Takes alphabet: "base64url" and lastChunkHandling. Check that it exists first, because Node 22 doesn't have it.
toHex() / fromHex()Bytes to hex, and back.Same support as the Base64 methods, so Node 25 and later. In Node 22, write hex by hand.
Base64, base64url, and hex methods on Uint8ArrayPop out in the code editor (opens in a new tab)JavaScript
const bytes = new Uint8Array([104, 105, 63]);if (typeof bytes.toBase64 === "function" && typeof Uint8Array.fromBase64 === "function") {  console.log(bytes.toBase64());  console.log(bytes.toBase64({ alphabet: "base64url", omitPadding: true }));  console.log([...Uint8Array.fromBase64("aGk_", { alphabet: "base64url" })].join(","));  console.log([...Uint8Array.fromBase64("SGk", { lastChunkHandling: "loose" })].join(","));} else {  console.log("native Base64 helpers unavailable");} const token = new Uint8Array([222, 173, 190, 239]);if (typeof token.toHex === "function" && typeof Uint8Array.fromHex === "function") {  console.log(token.toHex());  console.log([...Uint8Array.fromHex("deadbeef")].join("-"));} else {  console.log([...token].map((byte) => byte.toString(16).padStart(2, "0")).join(""));}

In plain words:

  1. Line 2 checks that the Base64 methods exist.
  2. In Chrome, lines 3 to 6 print aGk/, aGk_, 104,105,63, and 72,105.
  3. Line 12 checks that the hex methods exist.
  4. In Chrome, lines 13 and 14 print deadbeef and 222-173-190-239.
  5. In Node 22, the methods are missing. Line 8 prints native Base64 helpers unavailable, and line 16 prints deadbeef the manual way.

The Base64 methods take options:

  • { alphabet: 'base64url' } uses the URL-safe - and _.
  • { omitPadding: true } leaves out the = at the end.
  • Uint8Array.fromBase64 also takes lastChunkHandling. It says what to do with a short last group. "loose" accepts a last group without padding. The stricter settings either throw or stop before the short group.

Hex, short for hexadecimal, writes numbers in base 16 with the digits 0-9 and a-f. Every byte becomes exactly two hex digits. Until toHex works everywhere your code runs, you can write it by hand like this:

Hex by hand with padStartPop out in the code editor (opens in a new tab)JavaScript
const bytes = new Uint8Array([0, 15, 16, 255]);const hex = [...bytes]  .map((byte) => byte.toString(16).padStart(2, "0"))  .join("");console.log(hex); const roundTrip = Uint8Array.from(hex.match(/../g), (pair) => parseInt(pair, 16));console.log([...roundTrip].join(","));

In plain words:

  1. Line 3 turns each byte into base 16 with toString(16). Then padStart(2, "0") adds a 0 in front when there is only one digit, so 15 becomes 0f.
  2. Line 4 joins the pairs, and line 5 prints 000f10ff.
  3. Line 7 goes back. It cuts the hex into pairs and reads each pair as a base-16 number with parseInt.
  4. Line 8 prints 0,15,16,255.

Recipes for real websites

PLAYGROUND

Use this workbench to measure text. Type something, then compare how many code units, code points, and UTF-8 bytes it has. You also see it as hex, Base64, and base64url. The code is small on purpose: it encodes the text once, then builds every other form from the same bytes.

Text to bytes workbench
Workbench sourcePop out in the code editor (opens in a new tab)JavaScript
const text = "Aé€😀";const bytes = new TextEncoder().encode(text);const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");let binary = "";for (const byte of bytes) binary += String.fromCharCode(byte);const base64 = btoa(binary); console.log([...bytes].join(","));console.log(hex);console.log(base64);
Results10 bytes
UTF-16 code units5
Unicode code points4
UTF-8 bytes65, 195, 169, 226, 130, 172, 240, 159, 152, 128
Hex41c3a9e282acf09f9880
Base64QcOp4oKs8J+YgA==
base64urlQcOp4oKs8J-YgA
Try it yourself

Every result below comes from the same TextEncoder bytes. Change the text, then compare code units, code points, bytes, hex, Base64, and base64url.

Nothing you type is run as code. The workbench only encodes your text with the browser's built-in tools.

For a search form, use URLSearchParams, not Base64. It does percent-encoding. It writes each special URL character, and each character outside ASCII, as a % sign and two hex digits for each UTF-8 byte. A space becomes +.

Put text in a search queryPop out in the code editor (opens in a new tab)JavaScript
const query = new URLSearchParams({ search: "café & tea" });
console.log(query.toString());
console.log(new URLSearchParams(query.toString()).get("search"));

Line 2 prints search=caf%C3%A9+%26+tea. %C3%A9 is the two UTF-8 bytes of é, and %26 is &. Line 3 reads the original café & tea back.

A JWT (JSON Web Token) is a login token made of three parts, with dots between them. The middle part, the payload, is JSON written in base64url. Anyone can decode it, so "encoded" does not mean "private". Decoding it is useful to show or debug the payload. But before trusting it, the server must check the third part, the signature.

Decode a JWT payload partPop out in the code editor (opens in a new tab)JavaScript
function base64UrlToBytes(part) {  const base64 = part    .replace(/-/g, "+")    .replace(/_/g, "/")    .padEnd(Math.ceil(part.length / 4) * 4, "=");  const binary = atob(base64);  return Uint8Array.from(binary, (character) => character.charCodeAt(0));} const payloadPart = "eyJzdWIiOiIxMjMiLCJhZG1pbiI6ZmFsc2V9";const json = new TextDecoder().decode(base64UrlToBytes(payloadPart));console.log(json);console.log(JSON.parse(json).admin);

In plain words:

  1. Lines 2 to 5 turn base64url back into normal Base64. - becomes +, _ becomes /, and = is added until the length is a multiple of 4.
  2. Line 6 decodes it with atob, and line 7 turns the binary string into bytes.
  3. Line 11 decodes the bytes as UTF-8 text.
  4. Line 12 prints {"sub":"123","admin":false}, and line 13 prints false.

A data URL goes the other way. It puts a small file's bytes right inside a string that starts with data:. It is handy for tiny files and demos. But big Base64 data URLs make HTML and CSS files much larger.

Build a small data URLPop out in the code editor (opens in a new tab)JavaScript
const bytes = new TextEncoder().encode("Hello");const base64 = btoa(String.fromCharCode(...bytes));console.log(`data:text/plain;base64,${base64}`);

Line 1 encodes Hello into bytes, and line 2 turns them into Base64. Line 3 prints data:text/plain;base64,SGVsbG8=.

A hash turns any bytes into a short fingerprint of fixed size. SHA-256 is a common hash, and its result is called a digest. Here is how it works with text:

  • The Web Crypto API, crypto.subtle, hashes bytes, so first encode the text.
  • crypto.subtle.digest is asynchronous: it returns a promise, not the result.
  • The digest comes back as an ArrayBuffer. You choose how to print it, and hex is the usual choice.
SHA-256 digest as hexPop out in the code editor (opens in a new tab)JavaScript
async function sha256Hex(message) {  const bytes = new TextEncoder().encode(message);  const digest = await crypto.subtle.digest("SHA-256", bytes);  return [...new Uint8Array(digest)]    .map((byte) => byte.toString(16).padStart(2, "0"))    .join("");} sha256Hex("hello").then(console.log);

It prints 64 hex digits, starting with 2cf24dba: two digits for each of the 32 digest bytes. The code always uses lowercase digits, so two digests are easy to compare.

Base64 turns every 3 bytes into 4 characters. A last group of 1 or 2 bytes still takes 4 characters, with padding. So Base64 text is about one third bigger than the bytes, and very short values grow even more.

How big Base64 getsPop out in the code editor (opens in a new tab)JavaScript
for (const length of [1, 2, 3, 4, 12]) {  const base64Length = Math.ceil(length / 3) * 4;  console.log(length + " bytes -> " + base64Length + " Base64 chars");}

For 1, 2, or 3 bytes, the loop prints 4 characters. For 4 bytes it prints 8, and for 12 bytes it prints 16.

Pick the right form
  • new TextEncoder().encode(message)
  • new TextDecoder('utf-8', { fatal: true })
  • JWT payload part: eyJzdWIiOiIxMjMifQ
  • data:image/png;base64,...
  • 2cf24dba5fb0a30e...
  • byte.toString(16).padStart(2, '0')
Try it yourself
0 of 6 correct

Put each snippet in its group: turning text into bytes and back, Base64, or hex.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common misconceptions

  • "A JavaScript string is already UTF-8." It is a list of UTF-16 code units. You only get UTF-8 bytes when you encode it.
  • "btoa works on any text." It only takes binary strings, where each character is one byte. For other text, use TextEncoder first.
  • "Base64 makes data safe." It only makes bytes safe to carry as text. It does not encrypt, sign, escape, or check anything.
  • "A decoder always throws on bad bytes." By default it writes � and keeps going. Add { fatal: true } when bad input should stop the work.
  • "Hex and Base64 are the same thing." Both write bytes as text. Hex uses 2 characters for each byte. Base64 uses 4 characters for every 3 bytes, so it is shorter.

Each row below is a common mix-up. Read across: the tool, what it really is, and what to do about it.

Similar tools, different jobs
The toolWhat it really isWhat to do
btoa(text)It only works on binary strings.Encode the text with TextEncoder first, then turn the bytes into Base64.
Base64A way to carry bytes as text.Never use it to hide secrets. Anyone can decode it without a key, so it is not encryption.
HexAn easy-to-read way to write bytes.Expect it to be twice as long as the bytes: two characters for each byte.
TextDecoder with no optionsIt replaces bad bytes with �.Add fatal: true when broken input should stop the work.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict the UTF-8 bytes

Read the code and type the exact bytes it prints, in decimal.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const bytes = new TextEncoder().encode("é");
console.log([...bytes].join(","));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeWrite the hex output

    Predict the hex string. This shows why padStart(2, "0") matters.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const bytes = new Uint8Array([15, 16, 255]);
    const hex = [...bytes]
      .map((byte) => byte.toString(16).padStart(2, "0"))
      .join("");
    console.log(hex);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the Base64 bug

      The code throws. Type the name of the built-in tool that should run before btoa for any text.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      console.log(btoa("€"));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRead a JWT payload

        Type the boolean that the last line prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        function base64UrlToBytes(part) {
          const base64 = part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "=");
          return Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
        }
        const json = new TextDecoder().decode(base64UrlToBytes("eyJzdWIiOiIxMjMiLCJhZG1pbiI6ZmFsc2V9"));
        console.log(JSON.parse(json).admin);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWork out the Base64 size

          Some data has 5 bytes. How many Base64 characters will it take?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const length = 5;
          console.log(Math.ceil(length / 3) * 4);

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which sentence about JavaScript strings is true?

              Choose an answer to see the explanation.

            2. Question 2 of 7What bytes does UTF-8 use for €?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const bytes = new TextEncoder().encode("€");
              console.log([...bytes].join(","));

              Choose an answer to see the explanation.

            3. Question 3 of 7What does a TextDecoder with no options print for broken UTF-8?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const bad = new Uint8Array([0xC3, 0x28]);
              console.log(new TextDecoder("utf-8").decode(bad));

              Choose an answer to see the explanation.

            4. Question 4 of 7What does btoa('é') print in a browser?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              console.log(btoa("é"));

              Choose an answer to see the explanation.

            5. Question 5 of 7This code turns Base64 into base64url. What does it print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const base64 = "aGk/";
              console.log(base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""));

              Choose an answer to see the explanation.

            6. Question 6 of 7What hex string does this code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const bytes = new Uint8Array([0, 15, 255]);
              console.log([...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""));

              Choose an answer to see the explanation.

            7. Question 7 of 7Why is Base64 text about one third bigger than the bytes?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              for (const length of [3, 6]) {
                console.log(Math.ceil(length / 3) * 4);
              }

              Choose an answer to see the explanation.

            Key takeaways

            • A JavaScript string is a list of UTF-16 code units. Files and networks use bytes.
            • TextEncoder always writes UTF-8. TextDecoder reads bytes with the label you give it, and has options for broken bytes and chunks.
            • UTF-8 uses 1 to 4 bytes for each code point, so the byte count is not the string length.
            • btoa and atob only take binary strings. Encode other text to bytes first.
            • Base64 and hex write bytes as text. Anyone can decode them, so they are not encryption.
            • The new Uint8Array Base64 and hex methods are handy, but check that they exist first. Node 22 and older browsers don't have them.

            Remember the one-liner.
            Encode text before you treat it as bytes. Base64 and hex are ways to carry and read bytes, not ways to keep them secret.

            Up next: memory management and garbage collection. You'll learn how long objects, buffers, and references stay in memory, and when JavaScript cleans them up.

            CompleteFrontend Clear concepts. Working examples.