Beyond Code & Karma · Encoding

UTF-8 & Bytes

One glyph is not one byte — and not always one code point. How a Unicode code point becomes the 1 to 4 bytes a disk actually stores, why an emoji is four bytes, why .length lies, and the law of name and form hidden inside it.

Interactive Deep Dive · Unicode & UTF-8

A character has a name.
The bytes are its body.

Unicode assigns every character an abstract number — a code point, written U+0041 for "A" or U+1F600 for "😀". That number is an identity, not storage. To put it on a disk or a wire you must encode it. UTF-8 is the encoding that won the web: it turns one code point into 1 to 4 bytes, and — crucially — keeps every ASCII file byte-for-byte valid.

The one idea: code point ≠ byte ≠ character. "ॐ" is one code point, three UTF-8 bytes, and one visible character. "😀" is one code point, four bytes, and (to JavaScript's .length) two UTF-16 code units. Conflate them and bugs breed.
Code Point
The Unicode identity of a character — U+0950 for ॐ. A number from 0 to 0x10FFFF. No bytes yet, just a name.
Byte
Eight bits of real memory. UTF-8 packs a code point into 1 to 4 of them — the actual thing on disk.
Encoding
The rule that maps code point → bytes. UTF-8, UTF-16 and UTF-32 all encode the same code points into different byte shapes.
ASCII Compatibility
Code points 0–127 encode to a single 0xxxxxxx byte. Every ASCII file is already valid UTF-8 — the masterstroke that won the web.

Nama-rupa — the code point is the name, the bytes are the form

Hindu Philosophy Parallel

In Vedic thought, all manifest things are nama-rupa — name and form. The nama is the eternal, formless identity; the rupa is the body it wears to enter the material stream. A character is exactly this: the code point is its imperishable name, and the bytes are the form it takes to be stored and sent. The same syllable can wear 1 to 4 bytes — one meaning, many embodiments. The akshara ॐ, the "imperishable syllable", is itself a multi-byte being: U+0950 → three bytes, E0 A4 90.

Nama — the code point
The eternal name. U+0950 is ॐ in every system, on every machine, forever.
Rupa — the bytes
The form it wears to enter the stream. One name, 1–4 bodies, chosen to fit the world it enters.
ASCII — the simplest incarnation
The plainest body — a single byte. The most everyday descent into form, and the one UTF-8 leaves untouched.

"The name is one and imperishable; the forms are many and made of bytes. Read the name to know the meaning; read the bytes to know the cost."

How UTF-8 picks how many bytes

UTF-8 is variable-length and self-synchronising. The leading byte's high bits announce the length; every following byte starts with 10, so a decoder can always tell a leading byte from a continuation. Bigger code point → taller ladder.

1 byte · ASCII
0xxxxxxx — code points U+0000…U+007F. The 7 ASCII bits ride in one byte. No continuation, no overhead.
2 bytes
110xxxxx 10xxxxxx — up to U+07FF. Accents, Greek, Cyrillic, Hebrew, Arabic. 11 payload bits.
3 bytes
1110xxxx 10xxxxxx 10xxxxxx — up to U+FFFF. Most CJK, Devanagari (ॐ lives here), arrows, symbols. 16 payload bits.
4 bytes
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx — up to U+10FFFF. Emoji and the astral planes. 21 payload bits.

The leading byte decides everything

A single function maps a code point to its byte count — the same branch UTF-8 takes internally:

utf8-len.js
function utf8Len(cp) {
  if (cp < 0x80)    return 1;   // 0xxxxxxx
  if (cp < 0x800)   return 2;   // 110xxxxx 10xxxxxx
  if (cp < 0x10000) return 3;   // 1110xxxx 10xxxxxx 10xxxxxx
  return 4;                  // 11110xxx + three 10xxxxxx
}
Continuation bytes are the 10xxxxxx tails. They never start with 0 or 11, so they can't be mistaken for a leading byte — which is exactly what makes UTF-8 self-synchronising: drop into the middle of a stream and you can always find the next character boundary.

The three moves to encode one code point

Read the code point

Take the character's Unicode number — e.g. ॐ is U+0950 = 2384. This is the nama, the name to be embodied.

Choose the byte length

2384 is above U+07FF but below U+FFFF, so it needs 3 bytes: a 1110xxxx leader and two 10xxxxxx continuations.

Lay out the bits

Spread the 16 payload bits of 0x0950 into the x slots, left to right. Result: E0 A4 90 — the rupa, the form on disk.

The cheat-sheet

Code point rangeBytesBit patternExample
U+0000 – U+007F10xxxxxxx"A" → 41
U+0080 – U+07FF2110xxxxx 10xxxxxx"é" → C3 A9
U+0800 – U+FFFF31110xxxx 10xxxxxx 10xxxxxx"ॐ" → E0 A4 90
U+10000 – U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx"😀" → F0 9F 98 80

In code — the native encoder

You rarely lay out bits by hand. TextEncoder does it, in the browser and in Node:

encode.js
// The native encoder — code point → the UTF-8 bytes on disk.
const bytes = new TextEncoder().encode("ॐ");
// Uint8Array(3) [224, 164, 144]  →  0xE0 0xA4 0x90
console.log(bytes.length); // 3 — one akshara, three bytes

Watch a glyph become bytes

Type a character and watch it split into its code point and the UTF-8 bytes — header bits gold, payload bits cyan. Try the default , then an emoji.

UTF-8 teaser · code point → bytes
3 bytes
  • Header bits — announce byte count / mark continuation
  • Payload bits — the code point's actual value

UTF-8 vs UTF-16, and why .length lies

Three different things hide behind the word "character" — code points, UTF-8 bytes, and UTF-16 code units. Mixing them up is the single most common encoding bug.

Code Point
The Unicode identity. One per character, independent of any encoding. Count these with [...str].
UTF-8 byte
1–4 bytes per code point, ASCII-compatible, the web's default. Count with TextEncoder().encode(s).length.
UTF-16 code unit
What JS & Java use in memory: 1 or 2 16-bit units per code point. This is what .length counts — and where the lie lives.

The surrogate-pair gotcha

A 16-bit unit can't hold a code point above U+FFFF, so UTF-16 stores it as a surrogate pair — two units. That's why an emoji's .length is 2:

length-lies.js
"😀".length        // 2 — UTF-16 code units (a surrogate pair)
[..."😀"].length     // 1 — real code points
"😀".codePointAt(0) // 128512 → U+1F600

new TextEncoder().encode("😀"); // 4 bytes: F0 9F 98 80
Rule: use [...str].length or Array.from(str).length for code points, Intl.Segmenter for user-perceived characters (graphemes), and read .length only when you genuinely need UTF-16 code units (buffer sizing, slicing).

ASCII uses only code points 0–127, which all live in 7 bits. UTF-8 encodes exactly that range as a single 0xxxxxxx byte — the high bit always 0. So the bytes of an ASCII file and the bytes of the same text in UTF-8 are identical. This backward compatibility is the main reason UTF-8 displaced UTF-16 and the older code pages on the web: existing tools kept working unchanged.

Leading bytes start with 0, 110, 1110 or 11110; continuation bytes always start with 10. Those sets never overlap. So if you land in the middle of a multi-byte sequence — a truncated chunk, a random seek — you skip forward past the 10xxxxxx bytes until you hit a leading byte, and you're realigned. UTF-16 has no such guarantee inside a buffer of raw bytes.

A family emoji 👨‍👩‍👧‍👦 looks like one character but is a whole ZWJ sequence: man + woman + girl + boy, each a 4-byte emoji, glued by an invisible Zero Width Joiner (U+200D). Seven code points, twenty-five UTF-8 bytes, rendered as one grapheme. .length sees the units, [...str] sees the code points, and only Intl.Segmenter sees the one.

Off-by-one and corruption bugs cluster wherever the three counts get confused:

ScenarioThe trap
Char-limit validation.length rejects a 1-emoji bio as "2 chars". Use grapheme count.
Slicing stringsslice() can cut a surrogate pair in half → a lone surrogate, broken glyph.
DB column sizing"VARCHAR(10)" might mean 10 bytes, not 10 characters — an emoji eats four.
Reading byte streamsA chunk boundary can split a multi-byte sequence; decode with a streaming decoder.

The Karma of name and form

The Karma Connection

Karma is the law that the formless takes form to act in the world, and the form it takes is exactly fitted to the act. A code point — the eternal nama — does nothing until it descends into bytes to be stored, sent, read. The descent is not loss; it is how meaning enters the material stream.

nama-rupa.js
const name = "ॐ";              // nama — the eternal code point U+0950
const form = new TextEncoder() // rupa — the body it wears to exist
  .encode(name);               // [224, 164, 144] — 3 bytes, one meaning

The same name wears the body it needs — one byte for the plainest ASCII incarnation, four for an emoji rising from the astral planes. No name is privileged; each takes only the form its meaning requires. Read the bytes back and the name returns, undiminished — the form passes, the name is imperishable.

"The body is the cost of acting in the world; the name is what survives the decoding." — encoding, interpreted for systems engineers 🙏

UTF-8 Trivia

Three questions. No peeking at the visualizer. Your karma is watching.

Question 1
How many UTF-8 bytes does the emoji 😀 (U+1F600) take?
Question 2
Why is &quot;😀&quot;.length === 2 in JavaScript?
Question 3 — 🌶️ Spicy
In a multi-byte UTF-8 sequence, what do all continuation bytes start with?

The Six Truths of UTF-8

Code Point ≠ Byte
The code point is the name; bytes are the form. One character, one code point — but 1 to 4 bytes.
The Length Ladder
U+007F, U+07FF, U+FFFF, U+10FFFF are the four rungs. Each threshold adds a byte. Bigger code point, taller stack.
ASCII Rides Free
Code points 0–127 stay a single 0xxxxxxx byte. Every ASCII file is already valid UTF-8.
Continuations Start With 10
Every byte after the leader is 10xxxxxx. That single rule makes UTF-8 self-synchronising.
.length Counts Units
.length is UTF-16 code units, not characters. An emoji reads 2. Spread the string for code points.
Nama-rupa — Name & Form
Read the bytes and the name returns undiminished. The form passes; the code point is imperishable.

कर्म करो, फल की चिंता मत करो