Back to News & Insights
JavaScript August 27, 2026 · 5 min read

You're truncating bios with `.slice()`. `Intl.Segmenter` knows where the emoji actually end.

A 30-character bio limit that cuts off mid-emoji isn't a rendering bug — it's .length counting UTF-16 code units instead of what's on screen. Intl.Segmenter counts graphemes, words, and sentences the way a reader actually sees them, and every major browser supports it now.

You're truncating bios with `.slice()`. `Intl.Segmenter` knows where the emoji actually end.

Somebody fills in their bio: "I love ☕ and my family 👨‍👩‍👧‍👦!" Your limit is 30 characters. You call .slice(0, 30) before saving, same as you always do.

What gets saved is "I love ☕ and my family 👨‍👩" — followed by a dangling zero-width joiner with nothing after it. The family of four just lost two kids. On some renderers you get a lone half-emoji instead: a little black diamond with a question mark where a face should be.

Nobody touched your CSS. The bug is .length — and it's lying to you about what a "character" is.

.length isn't counting characters. It's counting UTF-16 code units — the 16-bit chunks JavaScript strings are actually made of. Most emoji live outside the range a single 16-bit unit can address, so the engine stores them as a surrogate pair: two code units that only mean something together. 😀 is one character and two units. .slice(), .substring(), and charAt() all work in code units, with no idea that a pair belongs together.

Spreading the string looks like the fix, because it iterates by code point instead of code unit:

The family emoji isn't one code point wearing a costume — it's four separate people emoji glued together with an invisible joiner character (U+200D, zero-width joiner):

Seven. Not one. [...str] split the sequence right back apart into pieces that mean nothing on their own. A national flag has the same issue in miniature — it's two "regional indicator" letters standing in for a country code, one grapheme made of two code points:

Every naive approach agrees the flag and the family are worth more than "1." A reader looking at the screen would tell you they're each one thing.

Intl.Segmenter doesn't count units or code points. It counts grapheme clusters — the actual visual units a reader perceives as one character, using the same Unicode rules that render the emoji in the first place:

Truncation gets the same fix — walk graphemes instead of code units, and the cut lands between characters instead of through one:

Paste your own string with an emoji sequence into it, drag the limit down, and watch .slice() mangle it while the grapheme-aware version doesn't.

granularity also takes "word" and "sentence", and both solve real problems .split(" ") can't:

isWordLike is what makes this useful for a live word counter — filter to segments where it's true and punctuation stops inflating the count. And because it's Intl, pass a different locale and the rules change with it: word segmentation for "ja" finds boundaries in Japanese text with no spaces at all, something .split(" ") can never do regardless of locale.

Sentence segmentation exists too, but it's worth knowing where it's honest about its limits: it still trips on abbreviations like "Dr." inside a sentence, splitting where a human wouldn't. Use it for rough chunking — a "read more" preview, a text-to-speech feed — not as a grammar-perfect sentence parser.

You don't need this for a codebase that only ever sees "hello world". You need it the moment user-generated text meets: Character-limit inputs — bios, tweet-style composers, SMS previews. Any limit enforced with .slice() can split a grapheme cluster and hand a broken half-emoji to the render pipeline. "Read more" truncation — same bug, more visible, because it's live on every card in a feed. Client-side word/character counters — .split(" ").length overcounts on punctuation and undercounts on languages without spaces. Cursor movement in custom text widgets — pressing "left arrow" once should skip one visual character, not land you inside a surrogate pair.

None of these are exotic. They're the first bug report you get once your app has users outside a narrow slice of scripts and emoji usage.

Intl.Segmenter shipped in Chrome and Edge in November 2020, Safari followed about five months later, and Firefox was the last of the three — it landed there in April 2024, which is the point the API officially became Baseline. There's no polyfill tax, no bundle-size argument against it — every browser your users are actually on has had it for a while now.

The fix for that bio field isn't a new dependency. It's swapping str.slice(0, n) for the grapheme-aware version above, three lines, and it stops being a bug the next person on your team has to rediscover.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation