DEV Community

Lucian (LKB)
Lucian (LKB)

Posted on • Originally published at lkforge.com

I built an open, reproducible typing-speed test — and it won't show a number until it earns one

Search "average typing speed" and you'll get confident numbers — 40 WPM, 65 WPM, "professionals hit 75" — almost always with no passage, no method, and no dataset behind them. I wanted one I could actually defend, so I built a live typing test that collects an open, anonymized dataset and, crucially, refuses to publish an average until it has enough data to mean anything.

It runs on Cloudflare Workers + D1. It's live, the dataset is downloadable, and every headline number is reproducible from a script. Here's how it's built and the decisions that keep it honest.

Why WPM is hard to measure fairly

Three problems sink most "typing speed" numbers:

  1. Comparability. If everyone types different text, WPM isn't comparable — a familiar sentence types faster than a technical one. Fix: one fixed, standardized passage for everyone.
  2. Gross vs. net. Counting every keystroke rewards fast-and-sloppy. Fix: net WPM — only characters that match the passage at the same position count, so mistakes lower your score.
  3. Cheating. A paste "types" 300 WPM. Fix: block paste, and reject anything outside a plausible human range server-side.

The scoring is deliberately boring and standard:

// a character counts only if it matches the passage at that position
let correct = 0;
for (let i = 0; i < Math.min(typed.length, passage.length); i++) {
  if (typed[i] === passage[i]) correct++;
}
const netWpm = (correct / 5) / (elapsedMs / 60000); // 5 chars = 1 "word"
const accuracy = correct / typed.length;             // as a percentage
Enter fullscreen mode Exit fullscreen mode

Keeping submissions honest (without accounts)

No login, but a few cheap gates keep the dataset clean:

  • A short-lived HMAC token fetched right before submit — blocks tokenless curl spam.
  • A 1–250 WPM plausibility bound — a paste or bot lands well above 250 and is dropped.
  • A per-visitor daily cap, so one source can't skew the set.
  • Privacy: the server never stores your IP. It keeps a salted SHA-256 hash purely for rate-limiting, and the public export contains only timestamp, wpm, accuracy.

The part I care about most: the honesty gate

With a few dozen samples, one fat-fingered run swings the average wildly. So the page hides the headline until it has 1,000 results, and reports the median (robust to outliers) rather than the mean:

function renderHeadline(d) {
  if (!d || d.count < 1000) {
    return `Collecting data — ${d ? d.count : 0} tests so far.`;
  }
  return `The median typist reaches ${Math.round(d.median_wpm)} net WPM ` +
         `at ${Math.round(d.median_accuracy)}% accuracy — over ${d.count} tests.`;
}
Enter fullscreen mode Exit fullscreen mode

Right now it genuinely says "collecting data — 0 tests." That's the point: I'd rather show an honest zero than a made-up average. Typing speed is a trained skill, so the honest description isn't a single number anyway — it's a distribution, which the page draws as a live histogram once results come in.

It's reproducible

The stats aren't a black box. There's a public CSV export and a small Node harness that recomputes every published figure — median WPM, median accuracy, trimmed mean, percentiles, histogram — using a verbatim copy of the site's own stats code:

node typing-speed-study.mjs
# fetches https://lkforge.com/api/typing/export and recomputes the numbers
Enter fullscreen mode Exit fullscreen mode

So the claim isn't "trust my dashboard," it's "here's the raw data and the exact math — run it yourself."

Try it (and feed the dataset)

The whole thing only becomes interesting with data, and it's at zero. If you want to add a point and watch the distribution build:

I'll write a follow-up with the actual distribution once there's enough data to report one honestly. Curious what people think of the honesty-gate approach — worth the "boring zero," or would you just show the running average with a caveat?

Top comments (0)