◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 28 days left. If your app calls it, check the migration target●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 28 days left. If your app calls it, check the migration target●SDK 58 ISSUE — Early reports include Android widgets not refreshing on a schedule and Stripe linking statically●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-08-04Advanced

Exactly 20 Characters, Still Rejected: Putting Character Counting Behind One Boundary

Emoji and combining marks make your client and your server disagree about length. I measured four counting methods on Node v22 and found 75.6% of naive truncations split a grapheme and 36.1% produced replacement characters over UTF-8. Here is the shared-module design that fixed it.

React Native242Expo234Intl.SegmenterUnicodeValidationArchitecture23

✦ Premium Article

One review came in with a single line: "I can't save my name."

I could not reproduce it. Japanese input worked. English input worked. The twenty-character limit behaved exactly as written. After a couple of exchanges I finally saw what the person had typed — a string of family emoji at the end of their display name.

When I typed the same thing myself, the counter under the field read "20 / 20" while the save button came back with a rejection from the server. The screen said the input was fine. The server said it was not. I had written both.

The cause turned out to be plain, and easy to miss. The client and the server were counting characters in two different ways.

As an indie developer maintaining several apps, this kind of half-correct bug accumulates quietly. What follows is the record of treating "how we count" as a design decision rather than an implementation detail. Every number below came from actually running the code on Node v22.22.3.

The same name, counted differently by every layer

Let me start by counting representative strings four ways: .length (UTF-16 code units), spread syntax [...s].length (code points), Intl.Segmenter (grapheme clusters), and UTF-8 byte length.

// seg_probe.mjs — four counting methods, side by side
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
const graphemeCount = (s) => {
  let n = 0;
  for (const _ of seg.segment(s)) n++;
  return n;
};
 
const samples = [
  ['ASCII', 'Hello'],
  ['Japanese', 'こんにちは'],
  ['Family (ZWJ)', '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}'],
  ['Skin tone modifier', '\u{1F44D}\u{1F3FD}'],
  ['Flag (regional indicators)', '\u{1F1EF}\u{1F1F5}'],
];
 
for (const [label, s] of samples) {
  console.log([
    label,
    s.length,                       // UTF-16 code units
    [...s].length,                  // code points
    graphemeCount(s),               // grapheme clusters
    Buffer.byteLength(s, 'utf8'),   // UTF-8 bytes
  ].join('\t'));
}

Here is what came back.

Input.length[...s].lengthGraphemesUTF-8 bytes
Hello5555
こんにちは55515
が (decomposed dakuten)2216
👨‍👩‍👧‍👦 (family)117125
👍🏽 (with skin tone)4218
🇯🇵 (flag)4218
葛󠄀 (with variation selector)3217
각 (decomposed Hangul jamo)3319
กำ (Thai)2216
❤️ (with VS16)2216
👩‍💻 (profession ZWJ)53111

The family emoji reads as 11, 7, 1, or 25 depending on who is asking. Every implementation returns a different answer for something a person perceives as one character.

The row worth pausing on is [...s].length. It is the first fix people reach for once they learn that .length is naive, and I wrote it that way for years. But the table shows it is not a step toward correctness. It counts the family emoji as 7 instead of 11, and neither number is 1. It is a partial fix that makes the remaining problem harder to notice.

How many emoji fit in a twenty-character field?

If your limit is twenty, the number of emoji that fit depends entirely on what "twenty" refers to.

Interpretation of "20"Family emoji that fitWhere this check tends to live
.length <= 201TextInput and its maxLength prop
[...s].length <= 202The validator you "fixed for emoji"
20 graphemes or fewer20What the user expects
20 UTF-8 bytes or fewer0Database column limits, API byte caps

One word, "twenty", spans a range from one to twenty. The last row is the nastiest: nothing fits at all. If any layer measures in bytes, a single emoji is enough to get the whole request rejected.

My bug was the gap between rows one and three. The client counted graphemes and displayed "20 / 20"; the server counted UTF-16 units and saw 220. Both were mine. The idea of aligning them had simply never come up.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A measured table where four implementations count the same family emoji as 11, 7, 1, and 25 respectively
✦Truncation damage measured over 2,000 strings: 75.6% split a grapheme cluster, 36.1% turned into U+FFFD once encoded as UTF-8
✦A dependency-free shared module that gives React Native and the server the exact same length verdict, plus a fallback for runtimes without Intl.Segmenter
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-06-22
When a Poisoned Cache Crashes Your App on Every Launch — Designing a Safe-Mode Boot Your Users Can Escape On Their Own
When a persisted cache goes bad and the app crashes at the same spot on every launch, the only option left to the user is to reinstall. This article designs a safe-mode boot for Expo (React Native): the app counts its own early crashes, confirms a launch only once it becomes interactive, and resets just the dangerous state in graduated steps.
⬡ Dev Tools2026-10-25
Sound Effects Stop at Play #40 — With expo-audio's createAudioPlayer, remove() Alone Isn't Cleanup
Creating a createAudioPlayer instance per play in expo-audio can leave Android silent after about 40 plays, with didJustFinish never arriving. What remove() and release() each do, three ways to restructure, and how to notice when it happens.
⬡ Dev Tools2026-09-10
I Added a Dim Screen Button, and iPhones Stayed Dark After the App Was Closed
In expo-brightness, setBrightnessAsync applies only to the current activity on Android, but changes the device brightness itself on iOS. Here is why the cleanup burden falls on one platform only, and a hook that restores brightness through AppState.