Text encoding & Base64
Convert JavaScript strings to UTF-8 bytes, Base64, base64url, and hex with TextEncoder, TextDecoder, atob, btoa, and typed arrays.
- 01Turn strings into bytes and backUse TextEncoder and TextDecoder, and remember that a JavaScript string is a list of UTF-16 code units, not bytes.
- 02Read bytes written as textExplain why UTF-8 uses 1 to 4 bytes, how Base64 padding and base64url work, and how hex writes each byte.
- 03Pick 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.
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.
"é" into the bytes 195 and 169. Decoding turns those bytes back into the same string.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.
| Form | What it is made of | Where you meet it |
|---|---|---|
| JavaScript string | UTF-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 bytes | 1 to 4 bytes for each code point. | In files, fetch bodies, databases, and most web formats. |
| Base64 | Text made of 64 safe characters. | In places that only accept text, like JSON and URLs. It often ends with = padding. |
| Hex | Two 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 THROUGHUnicode 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+007Fuse 1 byte. These are the plain English letters, digits, and symbols, called ASCII. - Code points up to
U+07FFuse 2 bytes, likeé. - Code points up to
U+FFFFuse 3 bytes, like€. - Bigger code points use 4 bytes, like most emoji.
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.
41c3 a9e2 82 acf0 9f 98 80The 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 small UTF-8 encoder. Watch each character pick its number of bytes, the same way TextEncoder does.
script
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(","));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.
const text = "é😀";
console.log(text.length);
console.log([...text].length);
console.log(new TextEncoder().encode(text).length);In plain words, line by line:
- Line 1 makes a string with two characters,
éand an emoji. - Line 2 prints
3.éis one code unit and the emoji is two. - Line 3 prints
2. Spreading a string with...splits it into code points. - Line 4 prints
6. UTF-8 uses 2 bytes foréand 4 bytes for the emoji.
€U+20AC226, 130, 172e2 82 ac€ is U+20AC. In UTF-8 its bytes are e2 82 ac (hex).
Use TextEncoder and TextDecoder for real work
REAL OUTPUTTextEncoder 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 newUint8Arraythat holds the bytes.encodeInto(text, destination)writes the bytes into aUint8Arrayyou already have. It returns an object with two numbers:read, how many code units it used, andwritten, how many bytes it wrote.
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:
- Line 3 prints
195,169,117,114,111. That is 2 bytes foré, then 1 byte each foru,r, ando. - Line 5 makes an empty
Uint8Arraywith room for 5 bytes. - Line 6 writes the bytes for
"éuro"into it. - Lines 7 and 8 print
4 code unitsand5 bytes. The 4 code units of the string needed 5 bytes. - 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.
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.
| Tool or option | What it converts | What to remember |
|---|---|---|
TextEncoder | A string into bytes. | It always uses UTF-8. Use encodeInto when you already have a Uint8Array to write into. |
TextDecoder | Bytes into a string. | Give it a label that matches the bytes, such as utf-8, utf-16le, or windows-1252. |
fatal: true | Broken bytes into an error. | It throws a TypeError instead of writing � (U+FFFD). |
stream: true | Bytes 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.
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:
- Line 2 reads the bytes as Windows-1252 and prints
café. - Line 3 reads the same bytes as UTF-8 and prints
caf�. The lone0xE9is not valid UTF-8. - Line 6 reads UTF-16 bytes, two bytes for each letter, and prints
Hi. - 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". - Line 9 prints
OK. By default,TextDecoderremoves the BOM. - Line 10 uses
ignoreBOM: true, so the BOM stays as the characterU+FEFF. It printsfeff.
What happens when the bytes are broken? There are two choices:
- By default, the decoder writes the replacement character
�(code pointU+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.
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.
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()withoutstream: 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
�.
c3, half of é. With { stream: true }, the decoder keeps c3 and gives "é" when a9 arrives.A decoder for chunks keeps a half-finished character until the next chunk arrives. Step through an é cut in two.
script
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));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 CASESBase64 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.
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.
const text = "é";
console.log(btoa(text));
const bytes = new TextEncoder().encode(text);
console.log(btoa(String.fromCharCode(...bytes)));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.
console.log(btoa("€"));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:
- Lines 1 to 3 make a helper that turns bytes into a binary string.
- Lines 4 to 6 make a helper that turns a binary string back into bytes.
- Line 8 encodes
€into UTF-8 bytes. - Line 9 turns those bytes into a binary string and passes it to
btoa. - Lines 10 and 11 print the bytes,
226,130,172, and the Base64,4oKs. - Line 12 goes all the way back:
atob, then bytes, thenTextDecoder. 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.
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 DETECTNewer 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, andUint8Array.fromBase64(text)turns Base64 back into bytes.bytes.toHex()turns bytes into hex, andUint8Array.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.
| Tool | What it converts | Where it works |
|---|---|---|
btoa / atob | A 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. |
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:
- Line 2 checks that the Base64 methods exist.
- In Chrome, lines 3 to 6 print
aGk/,aGk_,104,105,63, and72,105. - Line 12 checks that the hex methods exist.
- In Chrome, lines 13 and 14 print
deadbeefand222-173-190-239. - In Node 22, the methods are missing. Line 8 prints
native Base64 helpers unavailable, and line 16 printsdeadbeefthe manual way.
The Base64 methods take options:
{ alphabet: 'base64url' }uses the URL-safe-and_.{ omitPadding: true }leaves out the=at the end.Uint8Array.fromBase64also takeslastChunkHandling. 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:
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:
- Line 3 turns each byte into base 16 with
toString(16). ThenpadStart(2, "0")adds a0in front when there is only one digit, so 15 becomes0f. - Line 4 joins the pairs, and line 5 prints
000f10ff. - Line 7 goes back. It cuts the hex into pairs and reads each pair as a base-16 number with
parseInt. - Line 8 prints
0,15,16,255.
Recipes for real websites
PLAYGROUNDUse 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.
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);5465, 195, 169, 226, 130, 172, 240, 159, 152, 12841c3a9e282acf09f9880QcOp4oKs8J+YgA==QcOp4oKs8J-YgAEvery result below comes from the same TextEncoder bytes. Change the text, then compare code units, code points, bytes, hex, Base64, and base64url.
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 +.
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.
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:
- Lines 2 to 5 turn base64url back into normal Base64.
-becomes+,_becomes/, and=is added until the length is a multiple of 4. - Line 6 decodes it with
atob, and line 7 turns the binary string into bytes. - Line 11 decodes the bytes as UTF-8 text.
- Line 12 prints
{"sub":"123","admin":false}, and line 13 printsfalse.
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.
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.digestis 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.
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.
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.
new TextEncoder().encode(message)new TextDecoder('utf-8', { fatal: true })JWT payload part: eyJzdWIiOiIxMjMifQdata:image/png;base64,...2cf24dba5fb0a30e...byte.toString(16).padStart(2, '0')
Put each snippet in its group: turning text into bytes and back, Base64, or hex.
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.
- "
btoaworks on any text." It only takes binary strings, where each character is one byte. For other text, useTextEncoderfirst. - "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.
| The tool | What it really is | What to do |
|---|---|---|
btoa(text) | It only works on binary strings. | Encode the text with TextEncoder first, then turn the bytes into Base64. |
| Base64 | A way to carry bytes as text. | Never use it to hide secrets. Anyone can decode it without a key, so it is not encryption. |
| Hex | An 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 options | It replaces bad bytes with �. | Add fatal: true when broken input should stop the work. |
Practice exercises
5 EXERCISESRead the code and type the exact bytes it prints, in decimal.
const bytes = new TextEncoder().encode("é");
console.log([...bytes].join(","));TextEncoder writes é as the bytes 0xC3 and 0xA9. In decimal they are 195 and 169, so the line prints 195,169.
Predict the hex string. This shows why padStart(2, "0") matters.
const bytes = new Uint8Array([15, 16, 255]);
const hex = [...bytes]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
console.log(hex);The padded pairs are 0f, 10, and ff, so the joined hex string is 0f10ff.
The code throws. Type the name of the built-in tool that should run before btoa for any text.
console.log(btoa("€"));const bytes = new TextEncoder().encode("€");
const binary = String.fromCharCode(...bytes);
console.log(btoa(binary));TextEncoder turns € into the UTF-8 bytes 226,130,172. Line 2 turns those bytes into a binary string, and btoa prints 4oKs.
Type the boolean that the last line prints.
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);The payload decodes to {"sub":"123","admin":false}, so JSON.parse(json).admin prints false.
Some data has 5 bytes. How many Base64 characters will it take?
const length = 5;
console.log(Math.ceil(length / 3) * 4);Math.ceil(5 / 3) is 2 groups, and each group is 4 Base64 characters, so the length is 8.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which sentence about JavaScript strings is true?
Choose an answer to see the explanation.
Question 2 of 7What bytes does UTF-8 use for
€?Read the code, then predictconst bytes = new TextEncoder().encode("€"); console.log([...bytes].join(","));Choose an answer to see the explanation.
Question 3 of 7What does a
TextDecoderwith no options print for broken UTF-8?Read the code, then predictconst bad = new Uint8Array([0xC3, 0x28]); console.log(new TextDecoder("utf-8").decode(bad));Choose an answer to see the explanation.
Question 4 of 7What does
btoa('é')print in a browser?Read the code, then predictconsole.log(btoa("é"));Choose an answer to see the explanation.
Question 5 of 7This code turns Base64 into base64url. What does it print?
Read the code, then predictconst base64 = "aGk/"; console.log(base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""));Choose an answer to see the explanation.
Question 6 of 7What hex string does this code print?
Read the code, then predictconst 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.
Question 7 of 7Why is Base64 text about one third bigger than the bytes?
Read the code, then predictfor (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.
TextEncoderalways writes UTF-8.TextDecoderreads 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.
btoaandatobonly 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
Uint8ArrayBase64 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.