Character Count for Social Posts — Unicode Surprises
You wrote 278 “characters” in a textarea. value.length said 312. X rejected the post. Bluesky looked fine. Welcome to Unicode, where “character” means three different things depending on who you ask — and social networks invent a fourth called “weighted length.”
Three ways to count in JavaScript
| Metric | What it measures | Typical API |
|---|---|---|
| Code units | UTF-16 units | string.length in JS |
| Code points | Unicode scalars | [...str] or for…of |
| Graphemes | What users see | Intl.Segmenter |
const s = "👨👩👧👦";
s.length; // often 11 (code units)
[...s].length; // code points (still > 1)
[...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(s)].length; // 1
Preview drafts in a character counter when you care about more than raw length. For product UI, show graphemes to humans and keep platform-specific validation server-side or via each network’s API.
Platform limits are product rules
Ceilings change — treat these as starting points and verify in-app:
| Platform | Rough limit | Quirks |
|---|---|---|
| X / Twitter | ~280 weighted | Links often fixed length; emoji weighted; CJK may count differently |
| Bluesky | Higher plain text | Still grapheme-sensitive for emoji-heavy posts; facets for mentions/links |
| Mastodon | Instance-dependent | 500 common, not universal |
| LinkedIn / Threads | Product-specific | Do not reuse an X counter |
| SMS | Segments (160/70) | Emoji blows segment count; GSM vs UCS-2 |
Never ship a scheduler that only checks text.length <= 280. Weighted length on X means a URL might cost a flat 23-ish characters regardless of visible length — your local counter will lie unless you replicate their rules or call their preview API.
Emoji, ZWJ, and “invisible” budget
Skin tones, zero-width joiners, flags (regional indicators), and variation selectors consume budget invisibly. A single family emoji can be one grapheme and many code points. Smart quotes and non-breaking spaces pasted from Notion or Slack count too.
function graphemeCount(text) {
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
return [...seg.segment(text)].length;
}
UI copy that helps: “~240 graphemes · platform may weight emoji/links differently.” Red progress bars that use length will gaslight users who compose with emoji.
CMS, schedulers, and truncation bugs
Symptoms of a lying counter:
- Green dashboard check, then API 400
- Truncation mid-emoji (broken ZWJ → tofu / boxes)
- Link shorteners or UTM append changing length after schedule
- HTML entities in a CMS counting as multiple characters after unescape
Keep a native-composer dry run for launches and ads. Prefer grapheme-aware truncation libraries if you must cut text — never slice(0, 280) on UTF-16 indexes alone.
i18n realities
German compounds can explode a 240-grapheme English line. Chinese and Japanese can look short in a UI yet fail a byte-oriented SMS gateway. RTL marks and bidirectional isolates add code points users do not “see.” Build counters per channel — not one global maxLength={280} on every textarea in the design system.
Database and API validation
Know whether your DB VARCHAR(n) / TEXT limit is characters, code points, or bytes (MySQL utf8mb4 vs historical utf8). Validate the same way the downstream social API validates. Log both length and grapheme count when a publish fails — debugging without both is guesswork.
Threads and pre-publish checklist
Split long updates into threads? Count each post independently and keep a 5–10% buffer under the hard cap for last-minute hashtags and tracking params.
Before publish:
- Paste into the native composer once
- Expand preview links / UTMs
- Strip trailing spaces and zero-width characters from CMS
- Confirm emoji on iOS and Android
- Confirm whether storage limits are characters or bytes
- Re-check after any auto-shortener runs
String.length is fine for rough ASCII trimming. It is a liar for social copy. Count graphemes for humans, then validate with the platform’s own weighted rules before you hit “Schedule.”