Core answer: Character counting has three different answers: bytes (UTF-8: ASCII = 1 byte, Chinese = 3 bytes, emoji = 4), code points (JS string.length: Chinese = 1, emoji = 2!), and grapheme clusters (what humans call characters: family emoji 👨👩👧 = 1). WeChat/Weibo/Twitter limits count differently again. For user-facing limits, count graphemes; for storage, count bytes; never trust .length for either.
The three counting modes
| Text | UTF-8 bytes | JS .length | Graphemes |
|---|---|---|---|
| "hello" | 5 | 5 | 5 |
| "你好" | 6 | 2 | 2 |
| "🎉" | 4 | 2 (surrogate pair) | 1 |
| "👨👩👧" | 25 (3 emoji + 2 joiners) | 8 | 1 |
| "é" (composed) | 2 | 1 | 1 |
| "é" (decomposed e + ´) | 3 | 2 | 1 |
JS fixes: [...str].length counts code points; new Intl.Segmenter().segment(str) counts graphemes; new TextEncoder().encode(str).length counts bytes.
Platform limits (know your battlefield)
| Platform | Limit | Counting |
|---|---|---|
| WeChat 朋友圈 | ~1,500 chars practical | chars |
| 2,000 (members 5,000) | chars | |
| Twitter/X | 280 (weighted: CJK counts 2) | weighted |
| SMS | 70 Chinese / 160 ASCII per segment | bytes-ish (UCS-2) |
| Meta description (SEO) | ~150–160 chars visible | pixels, roughly |
Worked examples
Example 1 — The SMS split. "您好,您的验证码是123456" = 13 chars = 26 UCS-2 bytes — fits one 70-char segment. Add English? Mixed content still UCS-2. Over 70 → 67 chars/segment concatenated SMS, and carriers bill per segment.
Example 2 — Twitter weighting. 140 Chinese chars ≈ 280 weighted units = the whole tweet; an all-English tweet needs ~280 chars. Same limit, half the Chinese content.
Example 3 — Database column. VARCHAR(255) in MySQL utf8mb4 = 255 characters, up to 1,020 bytes; index prefixes limit to 767/3,072 bytes historically — emoji-heavy text blew up many 2015-era schemas.
Example 4 — Frontend counter. Build the counter with Intl.Segmenter so 👨👩👧 counts as 1 like users expect; fall back to [...str].length on ancient browsers.
Common mistakes and myths
- Trusting .length — "🎉".length === 2 breaks naive truncation: str.slice(0,1) yields a lone surrogate = tofu box �.
- Byte-budget confusion — a 1 KB limit fits 1,024 ASCII chars but only ~341 Chinese chars in UTF-8.
- Normalization traps — "é" as one code point vs e+´ are visually identical but string-unequal; normalize (NFC) before comparing or counting.
- Zero-width surprises — ZWJ (U+200D), variation selectors, and combining marks are invisible yet count; stripping them changes meaning (👨👩👧 → 👨👩👧).
- Assuming monospace width — CJK chars render double-width; terminal column counts differ from character counts (wcwidth exists for this).