Take the rocket emoji. It is one character. JavaScript says its length is 2. Stored as UTF-8 it occupies 4 bytes. All three numbers are correct, and they answer different questions.
const s = '๐';
[...s].length // 1 โ code points
s.length // 2 โ UTF-16 code units
new TextEncoder().encode(s).length // 4 โ UTF-8 bytesCode points: what a person means
A code point is one entry in the Unicode table. This is the count a person means when they say "characters", and it is what you want for a visible limit like "160 characters per SMS".
Spreading a string into an array iterates by code point, which is why [...s] gives the intuitive answer where s.length does not.
UTF-16 units: what JavaScript counts
JavaScript strings are sequences of UTF-16 code units. Anything above U+FFFF โ emoji, many CJK extensions, historic scripts, mathematical symbols โ is stored as a surrogate pair of two units, and .length counts both.
This matters more than it sounds, because every index-based string operation works in these units:
'๐ launch'.slice(0, 1)
// '\ud83d' โ half a surrogate pair, renders as ๏ฟฝTruncating a description to 200 characters with slice will eventually cut an emoji in half and emit a replacement character. Java, C# and Windows APIs all have the same model and the same hazard; Python 3, Go and Rust do not.
UTF-8 bytes: what storage counts
UTF-8 uses one byte for ASCII, two for most Latin and Cyrillic text, three for most CJK and four for emoji. This is what a VARCHAR limit, an HTTP header limit and a network payload measure.
helloโ 5 characters, 5 byteshรฉlloโ 5 characters, 6 bytesๆฅๆฌ่ชโ 3 characters, 9 bytes๐โ 1 character, 4 bytes
A field described as "255 characters" is very often 255 bytes. Japanese text hits that limit at 85 characters, and the failure arrives as a truncation or a database error rather than as a validation message.
Why encoding mismatches break hashes and signatures
Hashes and HMACs are defined over bytes, not characters. If one system encodes รฉ as UTF-8 (two bytes) and another as Latin-1 (one byte), they are hashing different input and will never agree, however correct both implementations are.
The same is true of Base64: it encodes bytes, so the text has to be converted to bytes first, and the choice of encoding is part of the contract. This is why btoa() in a browser throws on anything outside Latin-1 โ it has no encoding step, and refuses to guess.
btoa('cafรฉ')
// InvalidCharacterError
btoa(String.fromCharCode(...new TextEncoder().encode('cafรฉ')))
// 'Y2Fmw6k=' โ UTF-8 bytes first, then Base64Which one to use
- A limit shown to a person โ code points.
- A database column or an API payload limit โ UTF-8 bytes.
- Slicing, indexing, or anything using `.length` โ UTF-16 units, and be careful.
- Hashing, signing or encoding โ bytes, with the encoding stated explicitly.
Normalisation: two strings that look identical
Unicode can represent the same visible character in more than one way. รฉ is either one code point (U+00E9) or two (e followed by a combining acute accent). They render identically and compare as unequal.
const a = 'caf\u00e9'; // single code point
const b = 'cafe\u0301'; // e + combining accent
a === b // false
a.length // 4
b.length // 5
a.normalize('NFC') === b.normalize('NFC') // trueThis is not exotic. macOS has historically stored filenames in decomposed form while Linux stores whatever it was given, so the same filename copied between them can fail to match. The same applies to text pasted from different applications.
Normalise to NFC before comparing, hashing or storing anything a human typed. It is one call, and it removes a class of bug that is extremely hard to see in a debugger because both values print identically.
Where the counts bite in practice
- Twitter/X counts most characters as one and CJK as two, by its own rule โ neither code points nor bytes.
- SMS is 160 characters in GSM-7, but drops to 70 if the message contains a single character outside that alphabet. One emoji more than halves the limit.
- HTTP headers are bytes. A non-ASCII filename in
Content-Dispositionneeds the encoded form, or it is mangled. - Postgres `varchar(n)` counts characters, not bytes โ the opposite of MySQL. The same schema behaves differently on each.