RORK LABJP
CLOUD — Rork Max compiles native Swift on a fleet of cloud Macs, so you never download Xcode or need to own a MacPLATFORM — Rork Max targets iPhone, iPad, Apple Watch, and Vision Pro, and reaches games, widgets, and Live ActivitiesSHIP — Build in the browser, preview through a streaming simulator, install on device via QR code, and submit to the App Store without leaving RorkSPLIT — Regular Rork generates cross-platform apps with React Native and Expo. Reach for it to ship broadly and fast, and for Max when you need Apple-specific depthCREDIT — The free tier works out to roughly five prompts a week. It helps to budget the cost of trying something separately from the cost of finishing itPRICE — Rork Max sits on the $200/month Max plan, while regular Rork starts free with paid plans from $25/monthCLOUD — Rork Max compiles native Swift on a fleet of cloud Macs, so you never download Xcode or need to own a MacPLATFORM — Rork Max targets iPhone, iPad, Apple Watch, and Vision Pro, and reaches games, widgets, and Live ActivitiesSHIP — Build in the browser, preview through a streaming simulator, install on device via QR code, and submit to the App Store without leaving RorkSPLIT — Regular Rork generates cross-platform apps with React Native and Expo. Reach for it to ship broadly and fast, and for Max when you need Apple-specific depthCREDIT — The free tier works out to roughly five prompts a week. It helps to budget the cost of trying something separately from the cost of finishing itPRICE — Rork Max sits on the $200/month Max plan, while regular Rork starts free with paid plans from $25/month
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 Native218Expo156Intl.SegmenterUnicode2ValidationArchitecture21

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-07-08
When the Device Runs Out of Space, What Should Your App Protect?
Design Expo/React Native apps that assume writes can fail. Free-space budgets, LRU reclaim, data-protection tiers, and telemetry that surfaces silent failures — drawn from running six wallpaper apps.
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-06-02
Shipping Six Wallpaper Apps From One Codebase: A White-Label Build Setup with app.config.ts and EAS
Maintaining near-identical wallpaper apps in separate repos means every fix has to be copied six times — and one day you miss one. Here is the white-label setup I moved to: one codebase that emits six apps through a single APP_VARIANT, with the real app.config.ts and eas.json, plus a validation script that catches config drift before the build runs.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →