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.

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.
Open a console and check what one emoji costs:
.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:
That patches the surrogate-pair problem. It does not patch the next one.
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:
One. One. That's what .length should have said all along.
Truncation gets the same fix — walk graphemes instead of code units, and the cut lands between characters instead of through one:
Runs right in your browser — poke at it and watch the concept react live.
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(" ").lengthovercounts 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.
So — does your character counter know the difference between a code unit and what's actually on the screen? Go paste an emoji into your own limit field and find out.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.