~/blog

Your character counter is wrong about emoji, and .length is why

published

#javascript#unicode#emoji

A large grey rounded square to the left of a dashed green vertical cut line; to the right, a magenta rectangle above a strip of four smaller grey blocks
Generated illustration

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”:

UnitWhat it counts'👨‍👩‍👧‍👦''👍🏽''ñ' (NFD)
UTF-16 code units (.length)16-bit halves; anything above U+FFFF takes two1142
Code points ([...str], Array.from)Unicode scalar values; ZWJ and modifiers each count722
Grapheme clusters (Intl.Segmenter)What renders as one unit111

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:

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:

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

References