When a product team starts treating character counts as a first-class constraint, half the "obvious" assumptions quietly fall apart. Spaces that look identical render as different bytes. Truncation in the database is not the same as truncation in the view. A social card that fits at 220 units may overflow the moment someone pastes in a smart quote. None of these are exotic edge cases; they are the everyday residue of where string handling meets a fixed budget, and they are exactly the class of bug that escapes unit tests.
This walkthrough is for engineers who have to ship a text budget — a tweet composer, an SMS dispatcher, a meta-description editor, an SEO title field — and who want a defensible QA pass before release. We will go through the layered reference data that most counters hide, the rules that decide which length matters where, and a checklist you can drop into a pull-request template.
The Three Lengths That Compete Inside One Input
Every text field you ship has at least three numbers lurking behind it, and most production bugs come from a mismatch between two of them. A strlen in PHP gives you bytes; String.prototype.length in JavaScript gives you UTF-16 code units; Array.from(str).length gives you Unicode code points; a grapheme cluster iterator gives you what a human sees. None of these is universally wrong, but only one of them is what your downstream system counts.
The Unicode Standard, Chapter 3 defines code points as integers in the range U+0000 to U+10FFFF, and code point counts as the canonical reference value. Browsers, however, expose the older UTF-16 surrogate model to JavaScript, which is why "😀".length returns 2 even though the emoji is a single code point. The String.prototype.length page on MDN explicitly warns that surrogate pairs contribute two units each.
When your product's limit is expressed as "characters," pause and ask which one. Twitter's 280 limit counts Unicode code points (with weighted handling for CJK ranges). Most SEO snippets truncate on bytes once percent-encoded. SQL VARCHAR(255) on most engines counts characters under the database's collation, but CHAR_LENGTH and LENGTH disagree on UTF-8. The audit begins by pinning down which metric the consumer uses, not which one is easiest to compute.
A Reference Table You Can Paste Into Your Style Guide
Below is the kind of compact matrix that belongs in your repo's docs/text-limits.md. The exact thresholds vary per vendor and per year, so treat the numbers as an order-of-magnitude reference and re-verify against the current documentation before each release.
| Surface | Limit | Unit | Where truncation bites |
|---|---|---|---|
| Tweet body | 280 | Weighted code points | URL → 23; each CJK char → 2 |
| Meta description | ~160 visible, 920 rendered | Graphemes / px width | Mobile SERPs cut at ~130 |
| Title tag | ~60 visible, 600 rendered | Graphemes / px width | Pixel-based at ~512 px on Google |
| SMS (GSM-7) | 160 single, 153 concatenated | GSM alphabet chars | Concatenation resets the budget |
| SMS (UCS-2) | 70 single, 67 concatenated | Code points | Emoji force UCS-2 encoding |
| Push notification (iOS) | Title 40, body 178 | Bytes | Truncation appends "…" |
| Push notification (Android) | Title 35, body 78 | Bytes (most fonts) | Truncated silently if exceeded |
| Slack message | 40,000 | UTF-8 bytes | Threads split at ~40k |
A few callouts worth memorising. Google's search documentation states that the snippet length is determined dynamically up to about 160 characters and that pixel width matters as much as raw count for titles. For SMS, the GSM-7 alphabet has 128 code points listed in 3GPP TS 23.038, and any character outside that set forces the entire payload into UCS-2 — which is why one emoji silently halves your budget. Push notifications on Android truncate based on the byte size of the rendered string under the system font, not the source code point count, which is why Japanese text reaches the cap with fewer characters than English does.
Edge Cases That Routinely Slip Past Code Review
Even after you settle on a unit, a handful of inputs will quietly violate the budget. Build these into your fixture suite and you will catch most of them before a release.
Smart punctuation and dashes. A user pasting from Word inserts U+2019 (right single quotation mark) instead of U+0027 (apostrophe). Both render as ', but only the second is in GSM-7. Build a fixture for It's, It's, and It's (with U+00A0 no-break space) and confirm your counter flags the encoded-length cost, not the visual width.
Zero-width joiners. Family emojis such as 👨👩👧 are sequences of code points joined by U+200D. They look like one grapheme but allocate seven code points. The Intl.Segmenter API was added precisely to give you a correct grapheme count, and it is the only built-in that respects clusters.
Combining marks. A base letter followed by U+0301 (combining acute accent) is two code points and one grapheme. If your counter reports graphemes, "é" passes; if it reports code points, the same string overflows by one. Decide once, document, and test both shapes.
Right-to-left runs. Mixed Arabic and Latin text changes how many glyphs fit in a pixel budget, but does not change code-point count. Visual width assertions should run separately.
Normalisation drift. The byte count of "é" depends on whether the input was NFC or NFD-normalised. NFC: two bytes. NFD: three. If the field passes through multiple services, normalisation happens between them and the count shifts. The Unicode normalisation FAQ is the canonical reference; in practice you want one normaliser at the edge and an assertion that text === text.normalize("NFC").
A Pre-Ship Checklist You Can Adopt Today
The shortest path to consistent behaviour is a checklist that lives next to the pull-request template. Engineers tick the boxes once per surface, and reviewers reject if any row is blank.
- Identify the consumer. Write one sentence naming the system whose limit applies (Twitter, Google SERP, Twilio, Slack). Link to that vendor's current doc.
- Identify the unit. Code points, bytes, graphemes, or vendor-weighted. If unsure, default to code points and document the choice.
- Define a tolerance. Most teams pick the vendor limit minus 5–10% to absorb last-minute copy edits.
- Pick a counter. Use one in-app component that implements the chosen unit. Defer to the Lizely in-depth guide on counting characters for social media and SEO when a field crosses both surfaces (for example, an OG title that doubles as an in-app headline).
- Build fixtures. Cover each edge case above with one example string per row.
- Assert on encode. Round-trip the value through the wire format (UTF-8 bytes for HTTP, UCS-2 for SMS, JSON-escaped for storage) and assert byte length.
- Assert on render. Render at the smallest target viewport and capture a screenshot. Pixel width is the only check that catches font substitutions.
- Capture the contract. Add a one-row table to the component README with the limit, the unit, and a link to the vendor doc.
Trade-offs When You Build the Counter Yourself
Once you have chosen a unit, the implementation decision is which API to lean on. In JavaScript, the trade-off is essentially between three approaches, and the right answer depends on what the field's downstream consumer measures.
The legacy string.length is fast but reports UTF-16 code units, which undercounts BMP-non chars and overcounts the rest. Array.from(str).length walks the iterator protocol and yields code points, which matches what most vendor APIs mean by "character." The Intl.Segmenter instance yields grapheme clusters, which matches what a human sees. Segmenter is slower than the other two by an order of magnitude in tight loops, so reserve it for surfaces where the budget is measured in glyphs (titles, descriptions) and use code points elsewhere.
For server-side work, the same three layers exist in Python. len(s) gives code points under PEP 393, len(s.encode("utf-8")) gives bytes, and the third-party grapheme package — or regex with \X — gives clusters. In Rust, chars().count() is code points, .len() is bytes, and the unicode-segmentation crate is graphemes. Pick one crate or one method, document it, and forbid the others in code review.
A practical rule of thumb: if the consumer's documentation says "characters," assume code points unless you have evidence otherwise. If it says "bytes," assume UTF-8 on the wire. If it says nothing, your counter is the contract; pick the most conservative unit and make the choice visible.
Debugging the Mismatch You Inherited
Most teams land on this checklist after a bug. The signal is always the same: a user pastes text that the in-app counter says fits, the consumer says otherwise, and the round-trip is a few percent off. The diagnostic order is fixed and short.
First, isolate the input. Strip styling, paste into a hex editor, and confirm what bytes are actually present. Hidden U+200B zero-width spaces and U+FEFF byte-order marks are common culprits in copy-pasted copy. Second, encode to UTF-8 and compare the byte count to the counter. If they diverge, the counter is not measuring what you thought. Third, run the input through Intl.Segmenter and compare grapheme count to code point count; if they diverge, the field is sensitive to combining marks or joiners. Fourth, render the string in the target font at the target viewport and measure pixel width; if it overflows, no counter will save you because the budget is visual.
None of this is novel. The novelty is doing it before the bug ships, with a checklist rather than a Friday-night war room. The whole point of treating text as a measured resource is that the measurement is a contract, and contracts deserve fixtures, documentation, and review.
Frequently Asked Questions
Which character-count unit should I default to when the vendor says "characters"?
Code points. It is the unit the Unicode Standard defines, it is what Array.from(str).length returns in JavaScript, and it is what most vendor APIs mean by "characters" even when their docs are sloppy. Document the choice in the component README so future readers know what is being measured.
Why does my counter say 279 but Twitter rejects the tweet?
Twitter applies weighted counting: URLs collapse to a fixed 23-unit chunk, and most CJK ranges count as two units each. Plain code-point counting is the right default, but if your product mirrors Twitter's exact limits you have to replicate its weighting. The same applies to Mastodon, which uses a different table, and to BlueSky, whose limits have changed several times.
How do I count grapheme clusters in the browser without bundling a polyfill?
Use new Intl.Segmenter(undefined, { granularity: "grapheme" }). It is available in all evergreen browsers as of 2022 and returns an iterator whose .segment(text) yields objects with a .length property measured in code points. Sum those to get a grapheme count that handles ZWJ sequences and combining marks correctly.
Should the counter live in the form or in the database?
The form. The user needs feedback while typing, which means a counter that runs on every keystroke and that you can keep fast. The database layer should enforce a separate hard limit with a defensive assertion, because anything that bypasses the form (CSV import, API integration, legacy migration) still needs to be rejected at the boundary.
This article was drafted with AI assistance and reviewed for technical accuracy before publishing.
Top comments (0)