DEV Community

bowen Tse
bowen Tse

Posted on Originally published at toolkitloop.blogspot.com

Word Counting Is Harder Than It Looks: A Dev's Guide

Every developer has written the naive version at least once:

const words = text.trim().split(/\s+/).length;
Enter fullscreen mode Exit fullscreen mode

It works on a clean paragraph. Then a user pastes an essay and the number is wrong, and the bug report says your counter disagrees with Word, with Google Docs, and with itself on different browsers. The disagreement is not a rounding error. It is a segmentation problem, and once you see the rules, the naive version looks brave rather than correct.

Disclosure first: I built a free browser word counter at https://toolkitloop.com/word-counter, and this post explains the segmentation behaviour its own page documents. The tool counts entirely in the browser — nothing is uploaded — which is the right architecture for a counter, because pasting an unpublished essay into a server is a privacy ask nobody should have to make.

The naive split fails in specific, predictable places

Splitting on whitespace assumes every word is separated by a space. Real text breaks that assumption in at least five ways, and the ToolkitLoop tool page lists them as worked examples:

  • don't is 1 word. An apostrophe stays inside a word.
  • well-known is 1 word. A hyphenated compound stays together.
  • hello—world is 2 words. An em dash separates words.
  • 3.14 is 1 word. A decimal number counts as one word.
  • An emoji is 0 words, but it is still a character.

A whitespace split gets don't and well-known right by accident, gets hello—world wrong (it sees one token), gets 3.14 right by accident, and counts emoji as words, which users find plainly wrong. Worse, it fails completely on text that does not use spaces between words at all. Mixed text such as Chinese and English is segmented without relying on spaces alone, and a regex split has no answer for where one Chinese word ends and the next begins.

The browser already has a segmenter: use it

Modern browsers expose Intl.Segmenter, a language-aware segmentation API built for exactly this job. A word counter built on it looks like this:

function countWords(text) {
  if (!("Segmenter" in Intl)) {
    // Fallback for older browsers: whitespace split, documented as approximate.
    return text.trim() ? text.trim().split(/\s+/).length : 0;
  }
  const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
  let count = 0;
  for (const { segment, isWordLike } of segmenter.segment(text)) {
    if (isWordLike) count++;
  }
  return count;
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here. First, isWordLike is the filter that keeps emoji out of the word count while leaving them in the character count — the exact behaviour users expect. Second, the locale argument: passing undefined lets the browser use its default locale, which is a reasonable default for a general-purpose tool, but a counter aimed at one language should pass that locale explicitly and say so. Segmentation is locale-sensitive, and silently using the wrong locale is how you get a counter that is confident and wrong.

Character counts deserve the same care. Counting characters with text.length counts UTF-16 code units, not what a user sees. An emoji outside the basic multilingual plane is two code units, and a flag or family emoji is several. If your UI promises a "character count" and a user pastes emoji, length will over-count in a way that is visible and annoying. Intl.Segmenter with granularity: "grapheme" gives you the user-visible units; use it for the character number you display next to the word number.

Sentences, paragraphs and reading time are estimates, not facts

A counter usually shows more than words. Each extra number has its own failure mode, and honest UI copy should reflect that:

  • Sentences depend on terminator detection. Abbreviations (Dr., e.g.), decimals (3.14) and ellipses all break naive period-splitting. Intl.Segmenter with granularity: "sentence" handles the common cases better than a regex, but no segmenter resolves every abbreviation correctly.
  • Paragraphs are the most reliable number: split on blank lines and you are usually right, provided you decide whether a single line break is a paragraph break or a soft wrap in the pasted text. Document that choice.
  • Reading time is an estimate of time, not a measure of quality. It is computed from a words-per-minute assumption that varies by audience and purpose. Present it as "about N min", never as a precise figure.

The ToolkitLoop page states this plainly: reading time estimates how long a reader may spend with the text, not whether the copy is good or complete. That sentence belongs in your UI copy too, because users will otherwise treat the estimate as a grade.

Why counters disagree — and what to tell users

When a user says "Word says 512, your tool says 508", the correct response is not to defend your number. It is to explain that different tools apply different segmentation rules, and that the destination system is the authority for a strict limit. The practical advice that follows is genuinely useful:

  1. Paste the exact version you intend to submit, headings and bullets included, because counters count what you paste.
  2. Edit with your counter as a guardrail, not a judge.
  3. Verify the final text in the system that receives it — the editor, CMS or application portal — before submitting.

That third step is the one most counter UIs forget to say. A counter that claims to be the final authority is lying about a problem it cannot solve, since it cannot know the portal's rules. A counter that says "use the destination's count for a strict rule" is more trustworthy, and trust is the only moat a free tool like this has.

Building it locally is the whole point

There is a reason this kind of tool belongs in the browser with no backend. The input is often unpublished: an essay, a job application, a client draft, a manuscript. Asking a user to upload that text to count its words is a privacy cost with no technical justification — segmentation runs fine on the client. The tool page's promise is simple: counting runs locally in the browser, the text is not sent to a server or analytics service, and a word goal or limit setting stays on the user's own device. If you build a counter, that local-first shape is not a nice-to-have; it is the feature that makes the tool safe to recommend to students and writers.

The same reasoning applies to the rest of the numbers. Characters, sentences, paragraphs and reading time are all derivable on the client. Nothing in this feature needs a server, an account or a database. When a tool's value proposition is "paste your unfinished work here", the architecture is the privacy policy.

The checklist I would ship

  • Use Intl.Segmenter with granularity: "word" and filter on isWordLike; keep a documented whitespace fallback for older browsers.
  • Count characters as graphemes, not UTF-16 code units.
  • Show words, characters, sentences, paragraphs and reading time together, because each answers a different question.
  • Say plainly that the destination system's count wins for a strict limit.
  • Keep everything local: no upload, no account, no analytics on the pasted text.
  • Let users set a word goal or limit that persists on their device.

Try the edge cases in your own counter and tell me which one surprises you most: is it the em dash splitting hello—world, the emoji counting as zero words, or the mixed-language text that breaks a whitespace split entirely? The answer usually tells you which assumption your implementation is still hiding.

Top comments (1)

Collapse
 
jonrandy profile image
Jon Randy 🎖️ •

👍 Intl.Segmenter is an absolute must to do this properly. A good number of languages do not even put spaces between words - so splitting on whitespace is incredibly naive.