
A Brief Note on the Grapheme
Why your character counting logic is lying to you and how the Intl.Segmenter API finally reconciles JavaScript with the reality of human language.
Everything you think you know about the length of a string is a lie. If you’ve ever written a piece of code that limits a user’s bio to 280 characters using string.length, you’ve likely introduced a bug without even realizing it. In the world of modern web development, we treat strings like simple arrays of characters, but human language is far more chaotic than a sequence of 16-bit integers.
We’ve been conditioned to believe that one "character" equals one unit of memory. This was mostly true in the 1980s when ASCII reigned supreme and we only cared about the English alphabet. But today, the gap between what a computer sees and what a human sees has become a canyon. If you want to bridge that gap, you need to understand the grapheme.
The fundamental deception of .length
Let’s look at a quick example. Open your browser console and type this:
const emoji = "👨👩👧👦";
console.log(emoji.length); // Outputs: 11To your eyes, that is one character. It's a family of four. To JavaScript, it’s 11.
Wait, why 11? This happens because JavaScript strings are encoded in UTF-16. When you call .length, you aren't asking "How many characters are here?" You are asking "How many 16-bit code units are required to represent this string in UTF-16?"
The "Family: Man, Woman, Girl, Boy" emoji is actually a complex composite. It's made of four separate emojis joined together by "Zero Width Joiner" (ZWJ) characters. It looks like this under the hood:[Man] + [ZWJ] + [Woman] + [ZWJ] + [Girl] + [ZWJ] + [Boy]
Each of those emojis is a "surrogate pair" (two code units), and each ZWJ is one code unit. Add them up, and you get 11. If you use substring(0, 5) on that family emoji, you won't get a smaller family; you’ll get a broken mess of internal bytes that might render as a man and half of a woman.
Understanding the Terminology
To stop the lying, we need to get our definitions straight. There are three layers to every string:
1. Code Units: The smallest unit of storage (16-bit in JS).
2. Code Points: The unique number assigned to a character by the Unicode standard (e.g., U+1F600 for a grin).
3. Grapheme Clusters: What a human perceives as a single character.
A single grapheme can consist of multiple code points, and a single code point can consist of multiple code units.
I’ve seen plenty of "clever" fixes for this over the years. For a while, the standard advice was to use the spread operator or Array.from() to count characters.
const emoji = "👨👩👧👦";
console.log([...emoji].length); // Outputs: 7Better, but still wrong. It handled the surrogate pairs, but it failed to realize that the Zero Width Joiners were gluing those characters into a single visual unit. The spread operator sees the individual components, not the final result. It sees the bricks, not the house.
Enter Intl.Segmenter
For years, if you wanted to correctly count graphemes in JavaScript, you had to ship a massive library like grapheme-splitter or write a regex that looked like an incantation from a forbidden grimoire.
Then came Intl.Segmenter.
This is a relatively new addition to the Intl object (standardized in ES2022) that provides locale-sensitive text segmentation. It allows us to reach into a string and pull out meaningful chunks—whether those chunks are words, sentences, or, most importantly, graphemes.
Here is how you use it to finally get an honest character count:
const text = "👨👩👧👦 and café";
// Create a segmenter for a specific locale
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
// Segment the text
const segments = segmenter.segment(text);
// Convert to an array to see what happened
const result = Array.from(segments);
console.log(result.length); // Outputs: 10Let's break down that count of 10:
- "👨👩👧👦" (1)
- " " (2)
- "a" (3)
- "n" (4)
- "d" (5)
- " " (6)
- "c" (7)
- "a" (8)
- "f" (9)
- "é" (10)
Even the "é" is handled correctly, whether it's represented as a single code point (\u00E9) or a combination of "e" and a combining accent (\u0065\u0301). To Intl.Segmenter, it’s just one grapheme.
Why Locales Matter
You might wonder why we pass a locale (like "en" for English) to the segmenter. Does a character count really change based on where you live?
Yes.
In some languages, what constitutes a "single character" is subjective or follows different rules. For example, in Slovak, the character combination "ch" is often treated as a single letter in the alphabet. While most segmenters will still treat "c" and "h" as separate graphemes for cursor movement, having the locale-aware API means that as the Unicode standard and browser implementations evolve, your code will respect the linguistic rules of the user’s preferred language.
Practical Example: A Robust Character Counter
If you’re building a UI component with a character limit, you should probably stop using input.value.length. Here’s a small function that gives you the "Human" length of a string:
function getVisualLength(str, locale = 'en') {
const segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
let count = 0;
for (const _ of segmenter.segment(str)) {
count++;
}
return count;
}
const input = "Zalgo 𝙯𝙖𝙡𝙜𝙤 ⛳️";
console.log(`Units: ${input.length}`); // 18
console.log(`Graphemes: ${getVisualLength(input)}`); // 13Notice that I’m using a for...of loop over the segments instead of Array.from().length. This is a performance optimization. Converting the entire iterator into an array allocates memory for every single segment object. If you just need the count, just iterate and increment.
Beyond Counting: Word and Sentence Segmentation
The Intl.Segmenter isn't just for graphemes. It’s a Swiss Army knife for text analysis. How many times have you tried to split a string into words using str.split(' ')?
I’ve done it. It’s terrible. It fails on multiple spaces, it fails on newlines, and it fails on punctuation.
const sentence = "The 'Segmenter' API is cool, isn't it?";
const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" });
const words = [...wordSegmenter.segment(sentence)];
// Filter out the segments that are just whitespace or punctuation
const actualWords = words.filter(seg => seg.isWordLike);
console.log(actualWords.map(s => s.segment));
// ["The", "Segmenter", "API", "is", "cool", "isn't", "it"]The isWordLike property is a gift. It allows us to ignore the spaces and periods effortlessly. Try doing that with a regex that works across English, Japanese (which doesn't use spaces), and Arabic. You can't—or at least, you shouldn't try.
The Cost of Truth
There is no such thing as a free lunch. Intl.Segmenter is significantly slower than .length.
Accessing .length is an O(1) operation; the engine already knows the number of code units in the string. Segmenting a string is an O(n) operation that requires the engine to consult a massive internal database of Unicode properties to decide where the boundaries are.
In my testing, Intl.Segmenter can be 10x to 50x slower than basic string operations.
Does this matter? For a 280-character tweet? Not at all. For processing a 5MB text file in a loop? Absolutely.
If you're dealing with massive amounts of data, you should:
1. Segment on demand: Only calculate the grapheme count when the user pauses typing or before saving to a database.
2. Cache the segmenter: Creating a new Intl.Segmenter() is expensive. Create it once and reuse it.
// Don't do this in a loop
for (let text of largeArray) {
const count = [...new Intl.Segmenter('en').segment(text)].length;
}
// Do this
const memoSegmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
for (let text of largeArray) {
let count = 0;
for (const _ of memoSegmenter.segment(text)) count++;
}Edge Cases and Gotchas
Even with Intl.Segmenter, the world isn't perfect.
1. Browser Support
While support is excellent now (Chrome 87+, Edge 87+, Safari 14.1+, and Firefox 125+), you still need to be aware of users on older browsers. If you support older versions of Firefox, you’ll need a polyfill.
2. The "Flag" Problem
Unicode flags (like 🇺🇸) are made of "Regional Indicator Symbols." Intl.Segmenter generally treats a flag as a single grapheme. However, if you have a sequence of flags without spaces, different OS implementations sometimes disagree on where one flag ends and another begins if the sequence is invalid.
3. Tailoring
The Intl API relies on the underlying Operating System's ICU (International Components for Unicode) data. This means that, very rarely, you might see slight differences in segmentation between a user on an old version of Windows and a user on a brand-new macOS, simply because their Unicode databases are out of sync.
When should you use this?
You don't need Intl.Segmenter for everything. If you are generating a unique ID, checking if a string is empty, or parsing a CSV file, .length is exactly what you want. You are dealing with data, not language.
But you should use it whenever the string meets a human:
- Input Counters: If the UI says "10/20 characters," it should match what the human sees.
- Text Truncation: If you are cutting off a string with an ellipsis (...), use segments to ensure you don't cut an emoji in half.
- Cursor Logic: If you're building a custom text editor or a canvas-based UI, you need grapheme clusters to know where the cursor should move when the user presses the right arrow.
Reconciling with Reality
We like to think of programming as a world of pure logic, but web development is the intersection of logic and human messiness. The way we've handled strings for the last two decades has been a "good enough" approximation that is finally starting to crumble under the weight of global communication and expressive emoji usage.
The grapheme isn't just a linguistic curiosity; it's the actual unit of human writing. By using Intl.Segmenter, we stop pretending that language fits into neat 16-bit boxes and start writing code that actually understands the people using it.
Stop counting code units. Start counting characters. Your users (and their family-of-four emojis) will thank you.


