Why Every Tool Gives You a Different Character Count
One family emoji is 11 units, 7 code points, 1 grapheme and 25 bytes. Which number your limit means decides the bug.
Table of contents
- Key takeaways
- The four numbers, measured
- Why is String.length wrong for emoji?
- How do I count what the user actually sees?
- Which count should my limit use?
- Why does VARCHAR(n) not mean n characters?
- Two strings that look identical and are not
- A practical checklist
- Where the tools fit
- Frequently asked questions
- Why does my string length change after uppercasing?
- Is String.length ever the right count?
- How do I count characters the way Twitter or X does?
- Does Intl.Segmenter give the same answer in every browser?
- Why does my emoji break when I truncate it?
- Related reading
- References
Paste 👨👩👧👦 into four different tools and you can get four different answers: 11, 7, 1 and 25. All four are correct. They are counting different things, and none of them is "characters" in the sense a person means it.
That gap is where a whole family of bugs lives. The field that accepts a bio in the browser and rejects it at the API. The tweet that renders as 8 characters and costs 25 bytes. The truncation that turns a family into a single man.
Key takeaways#
String.lengthcounts UTF-16 code units, not characters. An emoji is usually 2, and a composed one can be 11.- Spreading a string (
[...s]) counts code points. Better, still not what a reader sees. Intl.Segmentercounts grapheme clusters, which is what "one character" means to a person.- A database
VARCHAR(n)may mean bytes or characters depending on engine and collation. - Two visually identical strings can have different lengths until you
normalize()them.
The four numbers, measured#
Same string, four counting methods. These are actual outputs, not illustrations:
| Input | .length | Code points | Graphemes | UTF-8 bytes |
|---|---|---|---|---|
hello | 5 | 5 | 5 | 5 |
é (e + combining) | 2 | 2 | 1 | 3 |
é (precomposed) | 1 | 1 | 1 | 2 |
日本語 | 3 | 3 | 3 | 9 |
नमस्ते | 6 | 6 | 3 | 18 |
👍🏽 (thumb + tone) | 4 | 2 | 1 | 8 |
🇧🇩 (flag) | 4 | 2 | 1 | 8 |
👨👩👧👦 (family) | 11 | 7 | 1 | 25 |
Read the last row again. One thing a person would call a single character is eleven units to String.length, and twenty-five bytes on the wire.
Why is String.length wrong for emoji?#
Because JavaScript strings are sequences of UTF-16 code units, and length reports how many of those there are. Any code point above U+FFFF is stored as a surrogate pair, meaning two units, so every emoji outside the basic plane counts as at least 2 before any modifiers are involved.
The family emoji stacks three separate mechanisms:
- Four base emoji, each a surrogate pair: 8 units.
- Three zero-width joiners (U+200D) gluing them together: 3 units.
- Rendered as one glyph by the font, because that sequence has a defined presentation.
Eleven units, seven code points, one thing on screen. The skin-tone thumb works the same way with a modifier instead of a joiner, and a flag is two regional indicator letters that a font draws as one image.
How do I count what the user actually sees?#
Use Intl.Segmenter with granularity: 'grapheme'. A grapheme cluster is the Unicode definition of "one user-perceived character", which is the thing a person is counting when they look at a text box.
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
const graphemes = (s) => [...seg.segment(s)].length;
'👨👩👧👦'.length; // 11
[...'👨👩👧👦'].length; // 7 (code points)
graphemes('👨👩👧👦'); // 1It is Baseline as of April 2024, so it is available across current browsers and in Node, with a FormatJS polyfill for older targets. Segmenting is more work than reading .length, so count on submit or on a debounce rather than on every keystroke of a long document.
The same tool fixes truncation, because you slice on cluster boundaries instead of units:
function truncate(s, max) {
const parts = [...seg.segment(s)].map((x) => x.segment);
return parts.length <= max ? s : parts.slice(0, max).join('');
}Which count should my limit use?#
Match the count to what the limit is protecting. Getting this wrong is how a field passes validation in the browser and fails at the database.
| The limit protects | Count | Why |
|---|---|---|
| A UI or a design | Graphemes | It is what the reader sees |
| A storage column | Bytes or chars | Whatever the engine actually enforces |
| An API contract | Whatever it says | Read the spec; do not assume |
| A cost or a quota | Bytes or tokens | Billing is measured in the units billed |
The failure mode is a client counting graphemes and a server counting bytes. Hi 👋🏽 is 4 graphemes, 7 code units and 11 bytes. A 10-byte column rejects a string the UI called four characters long, and the error surfaces nowhere near the input.
Why does VARCHAR(n) not mean n characters?#
Because n means different things per engine, and sometimes per column.
- PostgreSQL counts characters.
VARCHAR(10)holds ten characters regardless of their byte width. - MySQL counts characters too, but the storage depends on the charset.
utf8mb4reserves up to 4 bytes each, which is why an index on a longVARCHARcan exceed the key length limit. - SQL Server distinguishes types:
VARCHAR(n)is bytes,NVARCHAR(n)is characters. - SQLite ignores
nentirely. The length is documentation, not a constraint.
None of them count grapheme clusters. So a limit expressed to the user as "160 characters" is being enforced as something else everywhere below the UI, and the two only agree while the input stays ASCII.
Two strings that look identical and are not#
é can be one code point (U+00E9) or two (e plus U+0301). They render identically in every font. They are not equal:
const a = 'é'; // decomposed
const b = 'é'; // precomposed
a === b; // false
a.length; // 2
b.length; // 1
a.normalize('NFC') === b; // trueThis is why search misses a result that is visibly on the page, why a uniqueness constraint accepts an apparent duplicate, and why a diff shows a change in a line nobody edited. macOS filesystems hand you decomposed forms. Most Linux tooling produces precomposed. Copy a filename between them and the bytes differ.
Normalise at the boundary. Pick NFC, the web platform's default and the shorter form, then apply it once when text enters the system rather than scattering it through comparisons.
A practical checklist#
- Decide what the limit means before writing the validator. Graphemes for humans, bytes for storage.
- Enforce the same count on both sides. If the server counts bytes, the client counts bytes.
- Normalise on input, once, to NFC.
- Never slice on
.length. Segment, then slice. - Test with the family emoji. It exercises surrogate pairs, joiners and clusters in one string.
- Check what the column enforces, rather than what its declaration implies.
Step 5 is the cheapest of these and catches the most. If 👨👩👧👦 survives a round trip through your form, your API and your database intact, most of the rest is working.
Where the tools fit#
The character counter reports the counts side by side, which is the fastest way to see which number a given limit is actually using. The word counter is the one to reach for when the limit is editorial rather than technical.
The case converter covers the related trap: uppercasing is not always length-preserving. German ß becomes SS, and a string that fit no longer does.
Frequently asked questions#
Why does my string length change after uppercasing?#
Because case mapping is not one-to-one in every language. German ß uppercases to SS, adding a character, and some Turkish and Greek mappings change length too. A validator that checks length before normalising case can accept input that fails afterwards.
Is String.length ever the right count?#
Yes, when you are measuring what JavaScript will store or what an API defined in UTF-16 units expects. It is the wrong count whenever a person is being shown the number, because it does not match what they see.
How do I count characters the way Twitter or X does?#
You do not, reliably, from the outside. Platforms apply their own weighting: some ranges count double, and links are counted at a fixed length. The only accurate count is theirs. Count graphemes for your own UI and treat any platform limit as approximate.
Does Intl.Segmenter give the same answer in every browser?#
Grapheme segmentation follows a Unicode standard, so results agree in practice for the same Unicode version. Word and sentence granularity are locale-sensitive and can differ more, which is why the locale argument matters.
Why does my emoji break when I truncate it?#
Because the cut landed inside a surrogate pair or inside a joiner sequence. The first produces invalid text that throws in encodeURIComponent; the second produces valid text that means something else. Segment first, then slice on cluster boundaries.
Related reading#
- encodeURI vs encodeURIComponent — including the lone-surrogate
URIError - Base64 Is Not Encryption — why encoding text requires choosing an encoding first
- LLM Context Windows Explained — a fifth way to count the same string
- Compare the counts directly with the Character Counter and Word Counter.
References#
Tags
- Unicode
- JavaScript
- Text
- Encoding
- I18n
