Your character counter is wrong about emoji, and .length is why
published
TL;DR
'👨👩👧👦'.length is 11. Your user typed one character.
.length counts UTF-16 code units, not characters. If you are enforcing a limit, drawing a counter, or truncating a string, count grapheme clusters with Intl.Segmenter instead — and slice on the same boundaries, or you will cut an emoji in half and emit a lone surrogate.
The problem
A bio field is capped at 20 characters. A user pastes six emoji and the form rejects it. Another user types a name with an accent and the counter is off by one. A third gets their post truncated to end in �.
'a'.length; // 1
'👍'.length; // 2 — one emoji
'👍🏽'.length; // 4 — emoji + skin tone modifier
'🇲🇽'.length; // 4 — one flag
'👨👩👧👦'.length; // 11 — one family
Then truncation makes it worse:
'👍'.slice(0, 1); // '\uD83D' — half of a surrogate pair
'👍'.slice(0, 1) === '�'; // false — it is a lone surrogate, not U+FFFD
new TextEncoder().encode('👍'.slice(0, 1));
// Uint8Array(3) [239, 191, 189] — the lone surrogate becomes U+FFFD on encode
The last one is the expensive variant. Nothing throws; the damage happens at the encoding boundary, where an unpaired surrogate cannot be represented in UTF-8 and is replaced with U+FFFD. By the time you see the black diamond in production, the original bytes are gone.
Why it happens
JavaScript strings are sequences of UTF-16 code units, and length returns how many of those there are. Three different units are in play, and only the third one matches what a person means by “character”:
| Unit | What it counts | '👨👩👧👦' | '👍🏽' | 'ñ' (NFD) |
|---|---|---|---|---|
UTF-16 code units (.length) | 16-bit halves; anything above U+FFFF takes two | 11 | 4 | 2 |
Code points ([...str], Array.from) | Unicode scalar values; ZWJ and modifiers each count | 7 | 2 | 2 |
Grapheme clusters (Intl.Segmenter) | What renders as one unit | 1 | 1 | 1 |
The family emoji is four people (two code units each) joined by three zero-width joiners (one code unit each): 11. The spread operator iterates code points, so it sees the four people plus the three joiners: 7. Only grapheme segmentation, defined by UAX #29, collapses the whole sequence into the single thing the user sees.
Storage layers count in their own units too, which is where a “20 characters” rule quietly becomes three different rules:
- PostgreSQL —
varchar(n)stores “strings up toncharacters (not bytes) in length” (character types). Characters here means code points, so a family emoji costs 7. - SQL Server —
nvarchar(n)is measured in byte-pairs: “nnever defines numbers of characters that can be stored” (nchar and nvarchar). That is the same unit as JS.length, so a family emoji costs 11. - Byte-limited fields (HTTP headers, protocol frames, anything sized in octets) count UTF-8 bytes: 25 for that emoji.
What to do
Count and cut on grapheme boundaries. Intl.Segmenter has been Baseline since April 2024, and has been in Node.js since v16.
// Build it once — constructing a Segmenter is not free.
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
const graphemes = (s) => Array.from(graphemeSegmenter.segment(s), (g) => g.segment);
export const countChars = (s) => graphemes(s).length;
export const truncateChars = (s, max) => {
const g = graphemes(s);
return g.length <= max ? s : g.slice(0, max).join('');
};
countChars('👨👩👧👦'); // 1
truncateChars('👍🏽 hola', 2); // '👍🏽 ' — never splits the modifier off
Pick the unit deliberately at each layer:
- User-facing counters and limits — graphemes. It is the only count that matches what the person typed.
- Validation against a database column — whatever that column counts. Measure with
Array.from(s).lengthfor a Postgresvarchar,s.lengthfor SQL Servernvarchar,new TextEncoder().encode(s).byteLengthfor a byte-sized field. - Random access into a string — code points.
Array.from(s)is the cheap fix that stops surrogate splitting even if you do not need full grapheme handling.
If you cannot use Intl.Segmenter — a build target older than 2024, or a runtime without full ICU — Array.from is the fallback. It removes the lone-surrogate class of bug entirely; it just still counts a family emoji as 7 and a flag as 2.
To see what a specific string is actually made of, paste it into the Unicode inspector — it breaks input into code points and shows the encoding, which is usually faster than reasoning about it.
Caveats
- Grapheme count is not visual width.
'👨👩👧👦'is one cluster, but a font without that sequence renders four separate people. Terminal column width is a third question again, handled by East Asian Width, not by segmentation. - The locale argument matters most for
wordandsentence. Grapheme boundaries follow the UAX #29 default rules; passingundefinedis fine for counting. - Segmentation does not normalize.
'é'composed and'é'decomposed are both one grapheme and still fail===— a separate problem with a separate fix. - Do not reuse a Segmenter across granularities. One instance is bound to the granularity it was constructed with.
.lengthis not always wrong. When the limit genuinely is UTF-16 code units — a SQL Servernvarchar(n), a protocol field specified that way —.lengthis the correct measure and grapheme counting is the bug.- Deleting one grapheme is its own operation. Backspace in a text input deletes a cluster; if you implement custom editing, segment first rather than trimming code points.