Why "π¨βπ©βπ§".length Is 8, and How to Count What Users See
A user sets their display name to "Ana π¨βπ©βπ§" and your 20-character truncation renders "Ana π¨βπ©βοΏ½". The corpse of half an emoji, live in production. The root cause: .length doesn't count what anyone sees.
'π¨βπ©βπ§'.length; // 8 - eight UTF-16 code units
[...'π¨βπ©βπ§'].length; // 3 - three code points (better, still wrong)
// What a human sees: 1 character
That family emoji is three emoji glued with zero-width joiners. slice() cuts code units, spread cuts code points - both can split inside the glue. The unit users perceive is the grapheme cluster, and only one API speaks it:
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
function truncate(str, max) {
const graphemes = [...seg.segment(str)];
if (graphemes.length <= max) return str;
return graphemes.slice(0, max).map(s => s.segment).join('') + 'β¦';
}
truncate('Ana π¨βπ©βπ§ GarcΓa', 6); // "Ana π¨βπ©βπ§β¦" - the family survives intact
segment() returns an iterable of {segment, index} objects; joining the segments back reconstructs the original exactly. This also handles the less flashy cases that bite real text: accented characters composed from combining marks ("Γ©" as e + Μ), flag emojis, skin-tone modifiers, Devanagari conjuncts.
Word counting that respects languages
const words = new Intl.Segmenter('en', { granularity: 'word' });
function wordCount(text) {
return [...words.segment(text)].filter(s => s.isWordLike).length;
}
The isWordLike flag separates actual words from punctuation and spaces. The killer detail: split(/\s+/) counts zero words in Japanese or Thai, which don't use spaces - Segmenter with the right locale segments them correctly. If your CMS shows word counts or reading time on user content, this is the difference between a feature and a lie. (My own reading-time estimates run on exactly this.)
Sentence granularity, and what Segmenter is not
granularity: 'sentence' gives you first-sentence extraction for meta descriptions and previews - far more robust than splitting on ". " (which mangles "Dr. Smith" and "v2.5"). It's not a full NLP tokenizer, though: no part-of-speech tags, no hyphenation decisions, and sentence rules are heuristic. For UI-level text handling, that's plenty.
Support and the fallback
Chrome 87+, Safari 14.1+, Node 16+ - Firefox was last, landing in 125 (2024), so it's now safely universal for current browsers. For older targets, feature-detect and fall back to code-point spread - wrong for edge cases but strictly better than .length slicing:
const graphemes = 'Segmenter' in Intl
? [...new Intl.Segmenter().segment(str)].map(s => s.segment)
: [...str];
Once you've seen the .length lie, you spot it everywhere: character counters, database-length validations that disagree with the UI, avatar initials made of half a surrogate pair. Count graphemes, and users can put their families in their names in peace.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.