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.
What .length is actually counting
Open a console and check what one emoji costs:
"๐".length // 2
[..."๐"].length // 1
.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:
[..."๐"].length // 1 โ fixed
That patches the surrogate-pair problem. It does not patch the next one.
Where the spread trick still breaks
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):
const family = "๐จโ๐ฉโ๐งโ๐ฆ";
family.length // 11 โ code units
[...family].length // 7 โ code points (4 people + 3 joiners)
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:
const flag = "๐ฏ๐ต";
flag.length // 4
[...flag].length // 2
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.
The fix: segment by what a reader sees
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:
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
[...seg.segment(family)].length // 1 โ
[...seg.segment(flag)].length // 1 โ
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:
function truncate(str, max) {
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
const graphemes = [...seg.segment(str)].map(s => s.segment);
return graphemes.slice(0, max).join("");
}
truncate("I love โ and my family ๐จโ๐ฉโ๐งโ๐ฆ!", 26)
// "I love โ and my family ๐จโ๐ฉโ๐งโ๐ฆ" โ the family survives, intact
๐ฎ Try it yourself
โถ๏ธ Open the interactive playground โ
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.
It does more than graphemes
granularity also takes "word" and "sentence", and both solve real problems .split(" ") can't:
const words = new Intl.Segmenter("en", { granularity: "word" });
for (const s of words.segment("Hello, world!")) {
console.log(s.segment, "โ wordLike:", s.isWordLike);
}
// "Hello" โ wordLike: true
// "," โ wordLike: false
// " " โ wordLike: false
// "world" โ wordLike: true
// "!" โ wordLike: false
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.
Where this actually matters
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.
The part that makes this easy to adopt
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.
๐ง Test yourself
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___
Top comments (2)
The part that still bites after switching to graphemes is that a grapheme count does not bound anything downstream. Measured on Node 25.9: the family sequence is 1 grapheme and 25 UTF-8 bytes, so
truncate(str, 30)can hand the database 750 bytes, and a single grapheme has no upper size at all - 200 combining marks stacked on oneecame out as 1 grapheme and 401 bytes. If the 30 originally came from avarchar(30)or a byte-capped column, the grapheme-safe version still gets rejected, it just moves the failure from the renderer to the write. Worth checking which unit the downstream limit counts before picking the granularity.The zero width joiner case keeps surprising people because the split happens silently โ you'd never catch it in English test data. The underlying problem is that 'character' is underspecified: .length counts code units, spread counts code points, Intl.Segmenter counts grapheme clusters, each answering a different question. This gets interesting for AI pipelines because most LLM token counters count something else entirely: BPE boundaries that don't align with any of those three. Build a character limited bio input for an AI feature and also need to stay within a token budget, and you're managing two different length concepts at once. Is Intl.Segmenter's performance cost a concern at scale, or is it typically only called on short user input strings where it's negligible?