Character Count for Social Posts — Unicode Surprises

webdev unicode char-count social tools

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

MetricWhat it measuresTypical API
Code unitsUTF-16 unitsstring.length in JS
Code pointsUnicode scalars[...str] or for…of
GraphemesWhat users seeIntl.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:

PlatformRough limitQuirks
X / Twitter~280 weightedLinks often fixed length; emoji weighted; CJK may count differently
BlueskyHigher plain textStill grapheme-sensitive for emoji-heavy posts; facets for mentions/links
MastodonInstance-dependent500 common, not universal
LinkedIn / ThreadsProduct-specificDo not reuse an X counter
SMSSegments (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.”