Community Article
Community articles are authored by SitePoint Premium contributors. Content is screened before publication, and SitePoint reserves the right to moderate or remove articles that violate our guidelines. Views expressed are those of the authors and do not necessarily reflect those of SitePoint.
Your JavaScript Character Counter Is Probably Wrong: Unicode Explained
MQMohsin QureshiPublished inJavaScript·Vanilla JavaScript·Typography·
October 14, 2026
·Updated:October 14, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
Why string.length, emoji, grapheme clusters, and Intl.Segmenter can make a “simple” character counter surprisingly tricky.
A JavaScript character counter looks like a five-minute feature.
const count = text.length;For plain English text, that seems to work perfectly.
Then someone pastes an emoji.
Or a flag. Or an accented character. Or a family emoji joined from several Unicode code points.
The number on screen no longer matches what the person sees.
I ran into this while working with browser-based Unicode text transformations. The surprising part was not that Unicode is complicated; it was how long a broken assumption can stay hidden when every test string is just “Hello world”.
The core issue is that the word character can describe several different things in JavaScript: UTF-16 code units, Unicode code points, or grapheme clusters — the user-perceived characters displayed on screen.
If you build text inputs, username limits, editors, chat interfaces, social tools, or any frontend that accepts international text, choosing the wrong unit can produce bad counts, broken truncation, and inconsistent validation.
Here is how to handle JavaScript character counting correctly, and how to decide which definition of “character” your product actually needs.
const text ="hello";console.log(text.length);So far, so good.
const text ="😀";console.log(text.length);The user sees one symbol.
JavaScript reports two.
That is because JavaScript strings use UTF-16. Some Unicode characters fit in one 16-bit code unit, while others are represented as a surrogate pair.
String.prototype.length counts UTF-16 code units, not visible characters.
That behavior is correct according to the language. It is only wrong when the UI label says “characters” but the product really means “what the user sees”.
Three Different Things Developers Call a Character
For frontend text handling, it helps to separate these three layers.
| Unit | JavaScript approach | What it measures |
|---|---|---|
| UTF-16 code units | text.length | JavaScript’s internal string units |
| Unicode code points | [...text].length | Individual Unicode scalar values |
| Grapheme clusters | Intl.Segmenter | User-perceived characters |
For basic ASCII or ordinary English text, all three counts are often identical.
That is why a character-counter bug can survive testing for months.
The differences show up with emoji, combining marks, flags, skin-tone modifiers, and many non-Latin writing systems.
A Better First Step: Iterate by Unicode Code Point
One common improvement is to stop using:
text.split("")and use the string iterator instead:
[...text]const text ="😀";console.log(text.split("").length);console.log([...text].length);The spread operator iterates Unicode code points instead of blindly splitting UTF-16 code units.
Array.from() uses the same iterator behavior:
const chars =Array.from(text);console.log(chars.length);This is especially useful in text transformation code.
If you are mapping characters to alternate Unicode forms, this:
[...text].map(...)text.split("").map(...)because it does not tear supplementary characters into surrogate halves.
But code points still are not the same thing as visible characters.
Why One Visible Symbol Can Contain Several Code Points
const text ="👨👩👧👦";console.log([...text].length);A person sees one emoji.
The string contains multiple code points joined into one displayed grapheme.
Flags have the same kind of surprise:
const text ="🇺🇸";console.log([...text].length);The US flag is composed from two regional indicator code points, even though the interface renders one flag.
Accented text can also have more than one valid internal representation:
const composed ="é";const decomposed ="eu0301";console.log(composed);console.log(decomposed);They can look identical while containing different Unicode sequences.
For user-facing limits, the unit you usually want is the grapheme cluster — roughly, the character a person perceives and edits as one unit.
MDN’s JavaScript Internationalization documentation is a useful reference for the surrounding concepts:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Internationalization
Count User-Perceived Characters with Intl.Segmenter
Modern JavaScript gives us a native API for this: Intl.Segmenter.
const segmenter =newIntl.Segmenter(undefined,{granularity:"grapheme"});functioncountCharacters(text){return[...segmenter.segment(text)].length;}Now the results are much closer to what a user expects:
console.log(countCharacters("hello"));console.log(countCharacters("😀"));console.log(countCharacters("🇺🇸"));console.log(countCharacters("👨👩👧👦"));Intl.Segmenter can segment text by grapheme, word, or sentence.
For an emoji-safe JavaScript character counter, this is the important option:
granularity:"grapheme"For browser support and API details, see MDN’s Intl.Segmenter reference:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter
A Practical Character-Counter Helper
I prefer to keep segmentation logic in one helper instead of repeating it inside every input event.
const graphemeSegmenter =typeofIntl!=="undefined"&&Intl.Segmenter?newIntl.Segmenter(undefined,{granularity:"grapheme"}):null;functiongetCharacterCount(text){if(graphemeSegmenter){return[...graphemeSegmenter.segment(text)].length;}return[...text].length;}const input =document.querySelector("#message");const counter =document.querySelector("#counter");input.addEventListener("input",()=>{const count =getCharacterCount(input.value);counter.textContent=`${count}/ 280`;});The fallback counts code points rather than grapheme clusters, so it is not identical.
Still, it avoids the most obvious surrogate-pair problem in environments where Intl.Segmenter is unavailable.
Character Limits Are a Product Rule, Not Just a JavaScript Rule
Suppose a profile field allows 20 characters.
What does 20 actually mean?
- 20 UTF-16 code units
- 20 Unicode code points
- 20 grapheme clusters
- 20 bytes in a specific encoding
- 20 characters according to a database or API rule
Those limits are not interchangeable.
A display-name counter usually feels most natural when it counts grapheme clusters.
A database index, SMS payload, protocol field, or backend validator may have a completely different technical constraint.
The frontend should explain the user-facing rule, but it also needs to validate the same hard limit the backend actually enforces.
Unicode Normalization Solves a Different Problem
Where does one user-perceived character end?
Normalization answers a different question:
When should two Unicode sequences be treated as equivalent?
const a ="é";const b ="eu0301";console.log(a === b);They can render the same while comparing as different strings.
JavaScript provides String.prototype.normalize():
console.log(a.normalize("NFC")=== b.normalize("NFC"));Unicode defines four main normalization forms:
- NFC
- NFD
- NFKC
- NFKD
NFC is commonly useful when you want canonically equivalent text represented consistently.
Compatibility normalization such as NFKC goes further and can intentionally collapse distinctions between compatibility characters.
The Unicode Standard Annex on normalization explains those differences in detail:
Be careful with NFKC in decorative or styled Unicode workflows.
Compatibility normalization can change characters that were selected specifically for their alternate appearance.
const normalized = text.normalize("NFKC");should not be added to a text pipeline simply because normalization sounds safer.
First decide what equivalence your application actually needs.
Styled Unicode Is a Useful Frontend Stress Test
Unicode text tools are unusually good at exposing weak assumptions because they combine ordinary letters with mathematical alphabets, symbols, emoji, combining marks, and decorative characters.
While working on Live Font Generator:
I found that more varied test strings quickly reveal problems that never appear with plain ASCII.
That makes styled Unicode useful even if your production product is not a font generator.
Hello worldand conclude that counting, truncation, copy, and preview logic are all correct.
Hello 👨👩👧👦 🇺🇸 é नमस्तेand several edge cases show up immediately.
Use test data that challenges your assumptions, not just data that makes the happy path pass.
Truncation Can Break for the Same Reason
Character counting is only half the problem.
Truncation can split the same Unicode sequences.
const shortened = text.slice(0,10);slices UTF-16 code units.
const shortened =[...text].slice(0,10).join("");but it still slices code points, so a grapheme cluster can be cut in the middle.
For visible-character truncation, segment first:
functiontruncateGraphemes(text, maxLength){const segmenter =newIntl.Segmenter(undefined,{granularity:"grapheme"});return[...segmenter.segment(text)].slice(0, maxLength).map(item=> item.segment).join("");}Now your counter and truncation function use the same definition of a character.
That consistency matters more than it sounds.
10/10while the truncation logic damages the final emoji is a confusing product experience.
A Compact Unicode Test Set for Frontend Text Handling
You do not need hundreds of fixtures to catch the most common Unicode mistakes.
A small set is enough to expose many problems:
const cases =["Hello","café","eu0301","😀","👍🏽","🇺🇸","👨👩👧👦","नमस्ते"];Run these through any feature that:
- counts characters
- enforces a maximum length
- truncates strings
- transforms or normalizes text
- copies text to the clipboard
- generates usernames or display names
- renders live previews
- sends text to an API or backend validator
A small test set like this catches far more Unicode-specific issues than repeatedly testing with ordinary English sentences.
Common JavaScript Unicode Mistakes to Avoid
Using split("") for character iteration.
It splits UTF-16 code units and can break supplementary characters.
Assuming [...text] solves every Unicode problem.
It handles code points, not complete grapheme clusters.
Using string.length as a user-facing count without defining “character”.
The API is correct; the product assumption may not be.
Normalizing everything with NFKC.
Compatibility normalization can intentionally change distinctions in the original text.
Testing only ASCII or English.
Unicode bugs often stay invisible until emoji, accents, or non-Latin scripts appear.
Counting and truncating with different units.
Use the same segmentation rule for both.
Treating visual equality as string equality.
Two strings can render the same while containing different Unicode sequences.
Is JavaScript string.length wrong?
It correctly returns the number of UTF-16 code units in a JavaScript string.
It becomes misleading only when a product interprets that number as visible characters.
How Do I Count Emoji as One Character in JavaScript?
For user-perceived characters, use Intl.Segmenter with:
granularity:"grapheme"It handles many emoji sequences, flags, combining marks, and other grapheme clusters more accurately than string.length or code-point counting.
Is [...text].length Unicode-Safe?
It is safer than split("") because it counts Unicode code points rather than UTF-16 code units.
It still does not guarantee one element per visible grapheme cluster.
Should I Use Intl.Segmenter for Every Text Limit?
Not automatically.
Use it when the product limit is based on user-perceived characters.
Storage, protocol, API, byte, or database limits may require a different measurement.
Should I Normalize Text Before Counting It?
Not by default.
Normalization and segmentation solve different problems.
Normalize only when your application has a clear reason to treat equivalent Unicode sequences consistently.
Why Can the Same Unicode Text Look Different Across Browsers or Devices?
Unicode defines characters, but glyph rendering depends on fonts, operating systems, browsers, and fallback behavior.
The underlying text can remain identical while its appearance changes.
The Real Bug Is Usually the Assumption
The dangerous part of a JavaScript character counter is not the loop or the event listener.
It is the assumption hidden inside the word character.
If the number is meant for a person, grapheme clusters are usually the closest match to what they see and edit.
If the number exists because of a database, API, protocol, or storage rule, count whatever that system actually limits.
Before shipping your next text input, test:
- a flag
- an emoji with a skin-tone modifier
- a combining accent
- a joined family emoji
- at least one non-Latin script
If those survive counting, truncation, validation, and copy-paste intact, your “simple” character counter is finally measuring the thing you intended.
Mohsin is an SEO strategist and independent web publisher who builds and tests browser-based web tools, with a particular interest in Unicode text, frontend performance, and how content behaves across browsers and platforms.


