Why string.length Lies About Emoji (and Social Limits)

(Updated: July 16, 2026 ) unicode character count emoji javascript webdev

A teammate swears the caption is under 280. Chrome’s console says s.length === 240. The API returns 403 with “text too long.” Nobody is lying — they are counting different layers of Unicode.

Four layers people mix up

LayerWhat it countsTypical API
UTF-16 code unitsJavaScript string.lengthLegacy JS string APIs
Unicode code points[...str].length, for...ofMany “Unicode-aware” libs
Grapheme clustersWhat humans see as one characterIntl.Segmenter
Platform-weighted lengthProduct rules (links, CJK, emoji)Twitter/X, Bluesky, SMS

Emoji break every naive assumption. A single smiling face may be one code point. A skin-tone variant is base + modifier. A flag is a regional-indicator pair. A family emoji is several people + zero-width joiners. Your eyes see one glyph; the string may hold a dozen code units.

Surrogate pairs are the first trap. Code points above U+FFFF need two UTF-16 units in JavaScript. "𠮷".length === 2 even though it is one character. Slice with substring(0, 1) and you get a lone high surrogate — a broken string that can fail validation downstream.

What social platforms actually enforce

Product limits are rarely “graphemes ≤ N.” X weights URLs and some CJK differently. Bluesky documents grapheme-oriented limits but still surprises people with composed emoji. SMS and carrier gateways may count septets or UCS-2. If you ship a scheduling tool, you need a counter that matches the destination, not a generic “emoji-safe length.”

A practical approach for product UI:

  1. Show grapheme count for user-facing feedback (“looks like 42 characters”).
  2. Show platform estimate when posting to a specific network.
  3. Show UTF-8 bytes when the backend column is VARCHAR(255) in MySQL with a byte-oriented collation, or when an edge worker caps request bodies.

Database and API footguns

Postgres char_length / length on text counts characters in a database-defined sense; compare carefully with app-side JS. Redis keys and HTTP headers have octet limits. Elasticsearch analyzers may normalize emoji before indexing. Truncating at length - 1 in JS can split a surrogate pair and produce invalid UTF-16 that later JSON parsers reject.

Never store truncated user content without normalizing first. Prefer truncate-by-grapheme for display previews, and truncate-by-bytes only when a transport truly requires it — then document which rule you used.

A minimal JS checklist

// Code points (still wrong for ZWJ emoji families)
const codePoints = [...text].length;

// Graphemes (modern browsers)
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
const graphemes = [...seg.segment(text)].length;

// UTF-8 bytes
const bytes = new TextEncoder().encode(text).byteLength;

Feature-detect Intl.Segmenter. Polyfills exist, but shipping a server-side grapheme library for SSR keeps SSR and client counts identical.

Try a local counter before you ship the UI

Paste the same string into a character count tool that surfaces length, code points, and bytes side by side. Compare with the platform’s compose box. If they disagree, trust the platform for publish UX and keep your internal counter honest about which layer it measures.

Product copy and input limits

Set maxlength carefully: HTML maxlength counts UTF-16 code units in browsers, which again disagrees with grapheme expectations. If you promise “500 characters,” define the unit in the UI microcopy (“500 letters/emoji as shown”) and enforce the same rule server-side. Mismatched client/server limits produce the worst bug reports — valid on device A, rejected on API B.

For search boxes and tags, decide whether combining characters count separately. Linguists and game UIs often care; billing dashboards often do not. Document the decision next to the validator.

Teams that document “we count graphemes in the composer, UTF-8 bytes in the API, and code points in legacy analytics” stop arguing in Slack about whether 👨‍👩‍👧‍👦 is “one character.” It is one grapheme, many code points, and more code units than anyone expects — and that is normal Unicode, not a bug in your counter.