Emoji-Safe String Truncation in JavaScript with Intl.Segmenter
Why slice() and spread-based truncation corrupt emoji and accented text, how real products broke on it, and how to cut strings on grapheme boundaries instead.
This article is published in English.
Sources: I Was Truncating Strings Wrong
Almost every codebase with a bio field, a comment preview or a notification digest has a small helper that shortens text to a character limit. It usually looks correct, passes every test written for it, and then quietly corrupts the text of any user whose input contains an emoji near the cut-off point. This article explains what JavaScript is actually counting when you call .length and .slice(), walks through real bugs in well-known products that share the same root cause, and shows how to truncate on the boundaries a reader actually sees.
The helper everyone writes
Here is the version that shows up in countless codebases and tutorials: if the string is longer than the limit, cut it and append an ellipsis.
function truncate(str, max) {
return str.length > max ? str.slice(0, max) + '…' : str;
}
For English text it behaves perfectly, which is exactly why it survives so long. The failure only appears when someone's input contains, say, two emoji and the limit happens to land between them. Instead of a shorter string, the profile renders a replacement character: a black diamond with a question mark. Nothing about the function looks wrong at a glance, and a test suite made of ASCII strings will never catch it.
What .length actually counts
JavaScript strings are sequences of UTF-16 code units. Both .length and .slice() operate on those 16-bit units, not on characters in any human sense.
For Latin letters, digits and most common punctuation, one code unit equals one character, so the distinction is invisible. Characters outside the Basic Multilingual Plane, which includes most emoji, need two code units. That pair is called a surrogate pair: a high surrogate followed by a low surrogate, which together encode one code point.
'🐶'.length // 2
One dog on screen, a length of 2. If .slice() cuts between the two halves, you do not get a smaller emoji. You get a lone surrogate, which is not valid Unicode on its own. Browsers typically render it as the replacement character, and stricter consumers throw.
That is the first layer of the problem. There is a second, subtler one, covered below. First, it is worth seeing that this is not a theoretical concern.
The same bug in four real products
Twitter's 14-character flags
For years, Twitter counted tweet length roughly by code points. A plain thumbs-up emoji cost 2 characters, adding a skin tone pushed it to 7, and some flags cost as much as 14. The reason is that skin-tone variants and flags are built from several code points combined, and each one was counted.
In October 2018 Twitter changed the rule so that every emoji counts as 2, regardless of how many code points it contains. That weighting is still part of the counting rules in its open-source twitter-text library. See the coverage of the emoji count change and the X counting-characters documentation.
The case is useful because it separates two questions that are easy to conflate:
- How many visible characters does the user see?
- How should the product charge for, or limit, different kinds of text?
Twitter's answer to the second question is a product policy. It is not the same thing as measuring what is on screen.
Yjs and lib0: a split emoji that silently stopped sync
Yjs is a widely used library for collaborative editing, and TipTap builds on it. Yjs depends on a small utility package, lib0, whose splice helper used .slice() internally.
When a collaborative edit happened to fall between the two halves of an emoji, lib0 produced a string containing an orphaned surrogate. That string was later passed to encodeURIComponent, which throws a URIError when it meets a lone surrogate. Nothing caught the exception, so synchronization simply stopped. There is a detailed write-up of the lib0 surrogate bug.
What makes this one nasty is that it was invisible. The editor kept working locally. Users saw their own text and kept typing, with no indication that their changes were no longer reaching anyone else. The upstream fix detects a dangling surrogate at the edge of the sliced string and replaces it with the replacement character.
React Native TextInput and maxLength
React Native's TextInput exposes a maxLength prop, and issues about its emoji behavior have been filed since 2016, for example issue 10964 and issue 28774.
The reports follow the same pattern. Set a maxLength, type up to the limit, then add an emoji. Instead of rejecting the emoji or accepting it whole, the input cuts it at a code-unit boundary. Users have described seeing a different emoji from the one they typed, a box containing an "A", or on iOS, the entire field being cleared.
If your app has a username or bio field with a maximum length and you have never typed a skin-tone emoji right at the boundary, it is worth trying. A quick manual test set covers the main cases:
Hello 👋
Hello 👋🏽
Hello 🇮🇳
Hello 👨👩👧👦
Those four lines contain a simple emoji, one with a skin-tone modifier, a flag, and a family sequence joined by zero-width joiners. If your code treats each of them as a single character, it is probably counting something else.
Gemini CLI's truncateString
In March 2026, Google's Gemini CLI was found to have a truncateString helper that sliced at maxLength. Given a string ending in a crying-face emoji and a limit that fell inside it, the helper returned a lone \uD83D, the high surrogate by itself (issue 22753).
That is worse than cosmetic. A lone surrogate makes the string invalid UTF-16, and handing it to a JSON encoder or an API can cause failures further downstream. The fix was the one every team eventually arrives at: never let the cut point fall inside a surrogate pair.
Four products, four different years, one root cause. Emoji are just the most visible symptom of a broader issue: text does not split where JavaScript's indexes suggest it does.
Three levels of "character"
It helps to think of truncation strategies as operating at three levels, each counting a different unit.
Level 1: code units with .length and .slice()
This is what almost everyone reaches for first, because it is the obvious API. It is also the only one of the three that can split a surrogate pair. If your limit is 20 and the 20th code unit sits in the middle of an emoji, the result is corrupted text rather than a shorter string. The Yjs, Gemini CLI and React Native bugs all live at this level.
The mental gap is simple to state. JavaScript indexes UTF-16 code units; developers tend to believe they are indexing characters. The two happen to coincide for most English text, and that coincidence hides the problem.
Level 2: code points by spreading the string
A common fix is to spread the string into an array, which iterates by code point instead of by code unit.
const codePoints = [...str];
This keeps surrogate pairs together, so a single dog emoji stays intact. It is a real improvement, and many developers stop here believing the problem is solved.
It is not, because many things a person perceives as one character are made of several code points:
- A family emoji is several person emoji joined by zero-width joiner characters.
- A country flag pairs two regional indicator symbols, one per letter of the country code.
- A skin-tone variant appends a Fitzpatrick modifier to the base emoji.
- An accented letter can be a base letter followed by a combining mark.
Truncating by code point can still stop in the middle of one of these sequences. You avoid invalid UTF-16, but you can still show the user half a family, a lone regional indicator letter, or a hand without its intended skin tone.
[...'👋🏽']
// more than one item
The array has more than one element, yet a reader sees a single waving hand. Code points and user-perceived characters are different things, and that gap is exactly where the remaining bugs hide.
Level 3: grapheme clusters with Intl.Segmenter
The unit that matches what a reader sees is the grapheme cluster, and the built-in way to find grapheme boundaries is Intl.Segmenter with granularity: 'grapheme'. The helper below segments the string, collects each segment's text, and truncates the resulting array instead of the raw string.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
function truncateGraphemes(str, max) {
const graphemes = Array.from(segmenter.segment(str), s => s.segment);
return graphemes.length > max
? graphemes.slice(0, max).join('') + '…'
: str;
}
truncateGraphemes('Loving my new puppy 👨👩👧👦🐶🐶🐶!', 22);
// → "Loving my new puppy 👨👩👧👦🐶…"
Notice what happens in the example call. The family emoji counts as one grapheme and survives whole, the first dog fits within the limit of 22, and the cut happens cleanly before the second dog. No segment is ever split, because the function never indexes into the string directly.
Grapheme segmentation handles multi-code-point emoji, combining accents, and scripts such as Thai or Devanagari where one visible character is often several code points. Intl.Segmenter is part of the ECMA-402 internationalization specification. Chrome and Safari shipped it in 2020 and 2021, Firefox added it in April 2024, and Node.js has included it since version 16, so for current environments there is no polyfill cost to justify avoiding it.
It is tempting to reach for an emoji-specific library instead. That treats the visible symptom. Intl.Segmenter addresses the actual problem, which is that text boundaries are defined by Unicode rules, not by array indexes.
Details worth getting right
A few refinements are worth considering when you adopt the grapheme approach:
- The ellipsis is an extra character. If the limit is a hard constraint, such as a database column or an API field, reserve one grapheme for it or skip it.
- The example segments the entire string before truncating. For very long inputs you can iterate the segments and stop once you reach the limit, which avoids building a large array.
- Grapheme counts are not display widths. A CJK character or an emoji can occupy two terminal columns, so a CLI that aligns columns needs a width calculation on top of segmentation.
- To detect or repair strings that already contain lone surrogates, modern runtimes provide
String.prototype.isWellFormed()andtoWellFormed(), which are handy as a guard before callingencodeURIComponentor sending data to an API.
Where grapheme-aware truncation matters
Not every string needs this. For internal log lines or ASCII-only identifiers, grapheme segmentation is unnecessary work.
Look at where the bugs above occurred, though: tweets, shared documents, form fields and CLI output. Every one is a place where user-generated text receives a limit and is then shown back to people. Those are the places where the extra line of code pays for itself. They are also the places where the bug never appears in local testing, because test fixtures are ASCII, and appears in production on precisely the profiles and messages people look at most.
Typical cases to audit:
- Bio and profile fields. A "20 character" limit means 20 visible characters to the user, not 20 UTF-16 code units.
- Comment previews. A preview that ends in half an emoji looks broken, even though the full comment is fine.
- Notifications. A corrupted character in a push notification is highly visible and makes the whole product look unreliable.
- Collaborative editors. Text is split and merged between users constantly, so boundary handling directly affects data integrity.
- CLI output. Terminal tools increasingly display real user content, so Unicode handling is no longer only a browser concern.
The practical rule is short. Wherever a string may contain user-visible Unicode text, replace .slice(0, n) with a grapheme-based truncation, and prefer Intl.Segmenter over the spread trick. Spreading protects surrogate pairs; segmentation matches what a human sees as one character.
Common questions
Does Intl.Segmenter work in Node.js? Yes. It has been available without a flag since Node.js 16. If you still support an older runtime, confirm it before assuming you need a polyfill.
Is it slower than slice()? Somewhat, because it performs real segmentation instead of an index cut. For a bio field truncated once per render, the difference will not be measurable. If you are truncating thousands of strings in a hot loop, benchmark before and after.
Does this matter beyond emoji? Yes. Combining accents and many South and Southeast Asian scripts, including Thai, Devanagari, Tamil and Bengali, do not map one code point to one visible character. Intl.Segmenter handles them correctly; both .slice() and spreading can still get them wrong.
Should I use twitter-text instead? Only if you want Twitter's specific counting policy, where every emoji costs 2 and URLs cost 23. That is a business rule, not a correctness fix. To count what the user sees, Intl.Segmenter is the standard, built-in tool.
Wrapping up
The code-unit .slice() approach has been the default answer in "how to truncate a string" tutorials for a long time, and it has contributed to a miscounting character limit at Twitter, a silent sync failure in a collaborative editor, and long-running issues in React Native. The fix is not exotic. Decide which unit your limit is really about, and when it is about what people see, segment by grapheme cluster before you cut. If your codebase has a truncate helper, it takes a few minutes to find out which of the three levels it operates at, and a four-line test string to prove it.