DEV Community

Clackpit
Clackpit

Posted on

How I calculate WPM in real time — and why most typing sites get the math wrong

Most typing speed sites use a formula you can fit on a napkin:

WPM = (totalCharsTyped / 5) / elapsedMinutes
Enter fullscreen mode Exit fullscreen mode

Divide by 5 because a "word" is standardized as 5 characters. Divide by elapsed minutes to normalize for time. Simple, intuitive, and — in most steady-state situations — totally fine.

But if you're building a live typing race with real-time feedback, this formula will embarrass you pretty quickly.

Where naive WPM breaks down

Problem 1: The cold-start spike

You start a race. Within the first 0.1 seconds you've typed 2 characters. The formula says:

(2 / 5) / (0.1 / 60) = 240 WPM
Enter fullscreen mode Exit fullscreen mode

Congratulations, you are apparently the world's fastest typist before you've even finished the first word. This looks ridiculous on screen and tanks the credibility of the whole display.

Problem 2: What do you do with backspaces?

If someone types teh, backspaces to fix it, then types the, did they type 8 characters or 4? The naive formula counts raw keystrokes, which means a slow typist who makes tons of mistakes can paradoxically show a higher WPM than someone accurate — because they typed more total characters.

At race end, you want to reward accuracy. During a race, backspace-heavy typing should hurt through time lost, not inflate the WPM counter.

Problem 3: Rolling vs. cumulative

This is the one that bugs me most. A lot of sites show "live WPM" during a race, but what they're actually computing is your average from the beginning. Start fast, hit a hard word, and the number slowly drifts downward. It doesn't feel live — it feels like watching a lagging average.

Real live WPM should reflect what you're doing right now, not what you did since the first keystroke.

What Clackpit does instead

Here's the approach I landed on after a lot of iteration:

1. Don't show anything for the first 2 seconds

Before 2 seconds have elapsed, the WPM display just shows --. This kills the cold-start spike entirely. A 2-second delay is imperceptible to the user — they're focused on typing — but it prevents the ridiculous 500 WPM flash that naive implementations show.

2. Rolling 5-second window for live WPM

Rather than "chars typed since race start", I track a buffer of keystroke timestamps. The live WPM calculation only looks at correct characters typed in the last 5 seconds:

interface KeyEvent {
  timestamp: number; // ms since race start
  char: string;
  correct: boolean;
}

function getRollingWPM(events: KeyEvent[], nowMs: number): number {
  const WINDOW_MS = 5000;
  const MIN_ELAPSED_MS = 2000;

  if (nowMs < MIN_ELAPSED_MS) return 0;

  const windowStart = nowMs - WINDOW_MS;
  const recentCorrect = events.filter(
    (e) => e.correct && e.timestamp >= windowStart
  );

  if (recentCorrect.length < 2) return 0;

  // Use actual span of events in the window, not the full window size
  // This avoids underestimating WPM when the window isn't full yet
  const spanMs =
    recentCorrect[recentCorrect.length - 1].timestamp - recentCorrect[0].timestamp;

  if (spanMs < 200) return 0; // avoid division by near-zero

  const wpm = (recentCorrect.length / 5) / (spanMs / 60_000);
  return Math.round(wpm);
}
Enter fullscreen mode Exit fullscreen mode

The key detail: I use the actual span between the first and last event in the window, not the full window size. If you've only been typing for 1.5 seconds of a 5-second window, using 5000ms as the denominator would undercount your speed. Using the real span gives you a more accurate instantaneous rate.

3. Final WPM counts correct characters only

At race end, the score is:

function getFinalWPM(events: KeyEvent[], totalElapsedMs: number): number {
  const correctChars = events.filter((e) => e.correct).length;
  return Math.round((correctChars / 5) / (totalElapsedMs / 60_000));
}
Enter fullscreen mode Exit fullscreen mode

If you backspaced 20 times, those keystrokes don't boost your WPM — but they did cost you time, which is the natural penalty. You're not being double-punished, but you're also not being rewarded for typing characters that never made it into the final output.

4. Accuracy is tracked separately

function getAccuracy(events: KeyEvent[]): number {
  if (events.length === 0) return 100;
  const correct = events.filter((e) => e.correct).length;
  return Math.round((correct / events.length) * 100);
}
Enter fullscreen mode Exit fullscreen mode

This counts all keystrokes including backspaces in the denominator. Backspacing is a keystroke; it reflects how much correction happened. This is slightly different from how some sites calculate it (correct chars / total chars typed), but I think it's more honest about how efficiently you actually used your keyboard.

The tradeoff

The rolling window approach means your live WPM during a race is not directly comparable to your final WPM. Live WPM is "how fast are you going right now" and final WPM is "how fast did you go overall." They're measuring different things. I think that's actually correct — live feedback and final score serve different purposes — but it's worth being explicit about it in your UI so users aren't confused.


You can see all of this in action at Clackpit — the multiplayer typing race I built. Race starts, live WPM kicks in after 2 seconds, and your final score only counts the characters that mattered.

If you've built something similar and solved the cold-start problem differently, I'm curious — there's probably a dozen valid approaches here.

Top comments (0)