How Word, Grapheme, and UTF-8 Byte Counters Actually Work
Understand why Unicode grapheme clusters, UTF-16 code units, and UTF-8 byte lengths differ across social media, SMS, and SEO limits.
Engineering Summary & Key Takeaways
- JavaScript String.prototype.length counts 16-bit UTF-16 code units, not user-visible characters or database UTF-8 bytes.
- The ECMAScript Intl.Segmenter API (UAX #29) segments text into true user-perceived grapheme clusters, words, and sentences across international scripts.
- Multi-codepoint Zero-Width Joiner (ZWJ) sequences and accented characters can occupy 4 to 25 UTF-8 bytes while rendering as a single visible character.
- Social platforms, SEO snippets, SMS gateways, and SQL VARCHAR(N) columns each measure length using different underlying units.
1. Why Graphemes, UTF-16 Code Units, and UTF-8 Bytes Differ
In ASCII text (characters U+0000 through U+007F), one visible character equals one UTF-16 code unit and one UTF-8 byte. Outside basic ASCII, those three measurements diverge immediately.
JavaScript stores strings internally as sequences of 16-bit UTF-16 code units. Any Unicode code point above U+FFFF (such as mathematical symbols, historic scripts, or pictographs) requires a surrogate pair of two UTF-16 code units (string.length === 2) and four UTF-8 bytes. When multiple code points are joined by a Zero-Width Joiner (U+200D) or followed by a combining diacritical mark (such as "e" + U+0301), a single visible grapheme cluster can span 2 to 11 UTF-16 code units and up to 25 UTF-8 bytes.
| Sample Sequence | Visible Graphemes (Intl.Segmenter) | UTF-16 Code Units (str.length) | UTF-8 Byte Length (TextEncoder) |
|---|---|---|---|
| ASCII ("Hello") | 5 | 5 | 5 bytes |
| Precomposed Latin ("Caf\u00E9") | 4 | 4 | 5 bytes |
| Combining Accent ("Cafe\u0301") | 4 | 5 | 6 bytes |
| Sinhala Word ("\u0D86\u0DBA\u0DD4") | 2 | 3 | 9 bytes |
| 4-Person ZWJ Sequence | 1 | 11 | 25 bytes |
2. Word and Sentence Boundaries Under Unicode UAX #29
Naive word counters that split strings on whitespace (text.split(/\s+/)) miscount punctuation-only tokens as words, break on em-dashes, and fail completely on scripts without spaces between words (such as Japanese, Thai, or Chinese).
FetchToolKit uses the browser-native Intl.Segmenter API configured with granularity: "word" and granularity: "sentence", filtering segments where segment.isWordLike is true. This follows Unicode Standard Annex #29 (UAX #29) text boundary rules directly inside your browser.
const graphemeSegmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const wordSegmenter = new Intl.Segmenter('en', { granularity: 'word' });
const graphemes = Array.from(graphemeSegmenter.segment(input)).length;
const words = Array.from(wordSegmenter.segment(input)).filter((s) => s.isWordLike).length;
const utf8Bytes = new TextEncoder().encode(input).byteLength;3. Reference Table of Social, SEO, and Messaging Character Limits
When writing meta descriptions, social posts, or SMS notifications, exceeding a platform cap truncates your message or splits an SMS into multiple billed segments.
| Platform / Field | Target Limit | Counting Method | Truncation Behavior |
|---|---|---|---|
| SEO Meta Description | 150–160 chars | Visible characters / pixel width | Ellipsis (...) in search results |
| YouTube Video Title | 100 chars | UTF-8 / Grapheme count | Hard form validation cap at 100 |
| Instagram Profile Bio | 150 chars | Grapheme clusters | Hard cap upon saving profile |
| X / Twitter Post | 280 chars | Weighted Unicode code points (CJK/Emoji = 2) | Hard cap on standard accounts |
| SMS Segment (GSM-7 / UCS-2) | 160 / 70 chars | 7-bit GSM charset vs. 16-bit UCS-2 | Splits into multi-part 153/67-char segments |
Frequently Asked Questions
Why does a single non-ASCII character reduce an SMS limit from 160 to 70 characters?
Standard 160-character SMS messages use 7-bit GSM-7 encoding (1,120 bits max). Including even one character outside the GSM-7 table (such as curly smart quotes or non-Latin letters) forces the carrier to switch the entire message to 16-bit UCS-2 encoding, reducing the single-segment cap to 70 code units.
How does FetchToolKit keep the browser responsive when counting 500,000 words?
Whenever the input payload exceeds 100 KB (102,400 UTF-8 bytes), TextCounterEngine automatically offloads Intl.Segmenter traversal and keyword frequency aggregation to a background Web Worker (text.worker.ts).