When a user pastes 10,000 words into a text area and starts editing, every keystroke needs to update 20+ metrics simultaneously: words, characters, sentences, paragraphs, syllables, unique words, reading time, speaking time, keyword density, reading level, longest sentence, shortest sentence, average word length, and more.
If any of these calculations take longer than 16ms (one frame at 60fps), the user sees jank — a visible stutter in the typing experience. For a writing tool, that's unacceptable.
At TextWordCount, we process all 20+ metrics on every keystroke in under 1ms for texts up to 50,000 words. Here's how.
The Naive Approach (And Why It Fails)
The obvious implementation splits text on spaces, counts the result, and calls it done:
// ❌ Naive approach — breaks on edge cases, slow on large texts
function countWords(text: string): number {
return text.split(' ').filter(w => w.length > 0).length;
}
function countSentences(text: string): number {
return text.split(/[.!?]/).filter(s => s.trim().length > 0).length;
}
function countSyllables(text: string): number {
// Running regex on every word on every keystroke...
return text.split(' ')
.map(word => countWordSyllables(word))
.reduce((a, b) => a + b, 0);
}
Problems:
Multiple passes over the same text. Each metric function iterates the entire string independently. For 10,000 words, that's 10,000+ iterations × 20 metrics = 200,000+ iterations per keystroke.
Regex overhead compounds. Sentence splitting with
/[.!?]/and syllable counting with complex regex patterns are expensive. Running them independently on every keystroke creates measurable lag above ~5,000 words.Edge case failures.
split(' ')breaks on multiple spaces, tabs, newlines, and Unicode whitespace. Sentence splitting on/[.!?]/miscounts abbreviations (U.S.A., Dr., etc.) and decimal numbers (3.14).
Our Approach: Single-Pass Analysis
Instead of running 20 independent functions, we extract all metrics in a single character-by-character pass:
interface TextMetrics {
words: number;
characters: number;
charactersNoSpaces: number;
sentences: number;
paragraphs: number;
syllables: number;
uniqueWords: number;
lines: number;
longestSentence: number;
shortestSentence: number;
avgWordLength: number;
avgSentenceLengthWords: number;
avgSentenceLengthChars: number;
readingTimeSeconds: number;
speakingTimeSeconds: number;
handwritingTimeMinutes: number;
pages: number;
readingLevel: string;
keywordDensity: KeywordEntry[];
}
function analyzeText(text: string): TextMetrics {
const len = text.length;
if (len === 0) return EMPTY_METRICS;
let words = 0;
let sentences = 0;
let paragraphs = 1;
let syllables = 0;
let characters = len;
let charactersNoSpaces = 0;
let lines = 1;
let inWord = false;
let currentWordLength = 0;
let currentSentenceWords = 0;
let totalWordLength = 0;
let longestSentence = 0;
let shortestSentence = Infinity;
const wordFrequency = new Map<string, number>();
let currentWord = '';
for (let i = 0; i < len; i++) {
const char = text[i];
const code = text.charCodeAt(i);
// Character classification (single check, multiple uses)
const isWhitespace = code === 32 || code === 9 || code === 160; // space, tab, nbsp
const isNewline = code === 10 || code === 13;
const isSentenceEnd = code === 46 || code === 33 || code === 63; // . ! ?
// Characters without spaces
if (!isWhitespace && !isNewline) {
charactersNoSpaces++;
}
// Word boundary detection
if (isWhitespace || isNewline) {
if (inWord) {
// Word just ended
words++;
currentSentenceWords++;
totalWordLength += currentWordLength;
// Keyword tracking
const lower = currentWord.toLowerCase();
wordFrequency.set(lower, (wordFrequency.get(lower) || 0) + 1);
// Syllable counting for the completed word
syllables += estimateSyllables(currentWord);
currentWord = '';
currentWordLength = 0;
inWord = false;
}
} else {
inWord = true;
currentWord += char;
currentWordLength++;
}
// Sentence boundary (with abbreviation guard)
if (isSentenceEnd && i + 1 < len) {
const nextChar = text.charCodeAt(i + 1);
const isActualEnd = nextChar === 32 || nextChar === 10 || nextChar === 13;
if (isActualEnd && currentSentenceWords > 0) {
sentences++;
if (currentSentenceWords > longestSentence) longestSentence = currentSentenceWords;
if (currentSentenceWords < shortestSentence) shortestSentence = currentSentenceWords;
currentSentenceWords = 0;
}
}
// Line and paragraph counting
if (isNewline) {
lines++;
// Double newline = new paragraph
if (i + 1 < len && (text.charCodeAt(i + 1) === 10 || text.charCodeAt(i + 1) === 13)) {
paragraphs++;
}
}
}
// Handle last word (no trailing whitespace)
if (inWord) {
words++;
currentSentenceWords++;
totalWordLength += currentWordLength;
const lower = currentWord.toLowerCase();
wordFrequency.set(lower, (wordFrequency.get(lower) || 0) + 1);
syllables += estimateSyllables(currentWord);
}
// Handle last sentence
if (currentSentenceWords > 0) {
sentences++;
if (currentSentenceWords > longestSentence) longestSentence = currentSentenceWords;
if (currentSentenceWords < shortestSentence) shortestSentence = currentSentenceWords;
}
// Derived metrics (O(1) calculations)
const avgWordLength = words > 0 ? totalWordLength / words : 0;
const avgSentenceLengthWords = sentences > 0 ? words / sentences : 0;
const readingTimeSeconds = Math.ceil((words / 238) * 60);
const speakingTimeSeconds = Math.ceil((words / 183) * 60);
const handwritingTimeMinutes = Math.ceil(words / 13);
const pages = Math.ceil(words / 250);
// Keyword density (sort Map by frequency)
const keywordDensity = getTopKeywords(wordFrequency, words, 10);
// Reading level (Flesch-Kincaid)
const readingLevel = calculateReadingLevel(words, sentences, syllables);
return {
words,
characters,
charactersNoSpaces,
sentences,
paragraphs,
syllables,
uniqueWords: wordFrequency.size,
lines,
longestSentence,
shortestSentence: shortestSentence === Infinity ? 0 : shortestSentence,
avgWordLength: Math.round(avgWordLength * 10) / 10,
avgSentenceLengthWords: Math.round(avgSentenceLengthWords * 10) / 10,
avgSentenceLengthChars: 0, // calculated if needed
readingTimeSeconds,
speakingTimeSeconds,
handwritingTimeMinutes,
pages,
readingLevel,
keywordDensity
};
}
The key insight: one loop, one pass, all metrics. Character classification happens once per character and feeds multiple counters. Word boundaries trigger word-level calculations (syllables, frequency) only when a word completes — not on every character.
Syllable Estimation Without Heavy Regex
Syllable counting is the most expensive per-word operation. Academic implementations use CMU Pronouncing Dictionary lookups or complex NLP models. We use a lightweight heuristic:
function estimateSyllables(word: string): number {
const w = word.toLowerCase().replace(/[^a-z]/g, '');
if (w.length <= 2) return 1;
let count = 0;
let prevVowel = false;
const vowels = 'aeiouy';
for (let i = 0; i < w.length; i++) {
const isVowel = vowels.includes(w[i]);
if (isVowel && !prevVowel) count++;
prevVowel = isVowel;
}
// Silent e adjustment
if (w.endsWith('e') && count > 1) count--;
// Minimum 1 syllable
return Math.max(count, 1);
}
This runs in O(n) where n is word length — typically 4-8 characters. No regex, no dictionary lookups, no allocations. Accuracy is ~90% compared to CMU dictionary, which is sufficient for reading level estimation.
Debouncing Strategy
Even with sub-1ms analysis, we debounce the AI-heavy features while keeping basic metrics instant:
// Tier 1: Instant (every keystroke, <1ms)
// Word count, character count, basic stats
const instantMetrics = analyzeText(text);
updateUI(instantMetrics);
// Tier 2: Debounced (150ms after last keystroke)
// Keyword density sorting, reading level
const debouncedAnalysis = debounce(() => {
const detailed = calculateDetailedMetrics(text);
updateDetailedUI(detailed);
}, 150);
// Tier 3: Idle callback (when browser is idle)
// Social media limit calculations, page estimation
requestIdleCallback(() => {
updateSocialLimits(text);
});
This tiered approach means basic metrics update on every keystroke with zero perceived delay, while heavier calculations run during natural typing pauses.
Performance Benchmarks
Tested on a mid-range laptop (Intel i5, 8GB RAM, Chrome 120):
| Text Size | Analysis Time | FPS During Typing |
|---|---|---|
| 100 words | 0.02ms | 60fps |
| 1,000 words | 0.08ms | 60fps |
| 5,000 words | 0.31ms | 60fps |
| 10,000 words | 0.58ms | 60fps |
| 25,000 words | 1.4ms | 60fps |
| 50,000 words | 2.8ms | 59fps |
Even at 50,000 words — longer than most novels' chapters — the analysis completes well within a single frame budget (16ms). No Web Workers needed, no virtualization, no tricks.
Memory Efficiency
The Map<string, number> for word frequency is the largest allocation. For a 10,000-word text with ~2,000 unique words, this consumes approximately 100KB. We clear it on each analysis pass rather than maintaining a persistent data structure, trading a small amount of GC pressure for simpler code and guaranteed memory bounds.
// No persistent state between analyses
// Each call to analyzeText creates a fresh Map
// GC reclaims the previous Map naturally
Key Takeaways
Single-pass beats multiple specialized functions. One loop extracting 20 metrics is faster than 20 functions each running their own loop.
Character-by-character beats regex for streaming analysis. Regex creates intermediate strings and backtracking. A charCode comparison is a single CPU instruction.
Tiered debouncing preserves perceived performance. Users need word count instantly. They can wait 150ms for keyword density.
Heuristic syllable counting is good enough. 90% accuracy at 100x the speed of dictionary lookup is the right tradeoff for real-time analysis.
Measure before optimizing. Our first version used split/filter/map chains and was fine up to 2,000 words. We only rewrote when users reported lag on longer documents.
Links:
🌐 TextWordCount
🔧 All Tools
📚 Blog
🛒 Store
💼 LinkedIn
📘 Facebook
📺 YouTube
📊 Crunchbase
✍️ Medium
İsmail Günaydın — Founder of TextWordCount. Full-stack web engineer building privacy-first writing tools. LinkedIn · Portfolio

Top comments (0)