Build a Reaction Time Test in Vanilla JavaScript (and Measure It Accurately)
A reaction time test looks trivial: a box turns green, you click, and the page shows how many milliseconds you took. The part that trips people up is the measurement. This tutorial builds the whole thing in about 60 lines of vanilla JavaScript, and explains the three details that decide whether your numbers can be trusted.
You will build:
- A box that waits a random time, then turns green
- Millisecond timing with
performance.now() - Handling of clicks that come too early
- Best, average and median results across several tries
No libraries and no build step. Everything fits in one HTML file.
1. The markup
<div id="box" role="button" tabindex="0" aria-live="polite">Click to start</div> <p id="result"></p> <style> #box { display: grid; place-items: center; height: 220px; border-radius: 12px; background: #2563eb; color: #fff; font: 600 1.25rem system-ui; user-select: none; cursor: pointer; } #box.wait { background: #dc2626; } #box.go { background: #16a34a; } </style> The aria-live attribute makes screen readers announce the state changes, and tabindex lets keyboard users focus the box. We will wire up the Enter and Space keys too.
2. The state machine
A reaction test has four states, and most bugs come from letting them overlap. Keep the state in one variable:
const box = document.getElementById('box'); const out = document.getElementById('result'); let state = 'idle'; // idle, waiting, ready, finished let timer = null; // setTimeout handle let startedAt = 0; // performance.now() when the box turned green const times = []; 3. Measure with performance.now()
Date.now() can jump when the system clock changes, and it only has millisecond resolution. performance.now() is monotonic, which means it never goes backwards, so it is the right tool for measuring a duration.
function begin() { state = 'waiting'; box.className = 'wait'; box.textContent = 'Wait for green...'; const delay = randomDelay(); timer = setTimeout(() => { state = 'ready'; box.className = 'go'; box.textContent = 'Click now!'; startedAt = performance.now(); }, delay); } function randomDelay(min = 1500, max = 4500, rnd = Math.random) { return Math.round(min + rnd() * (max - min)); } The delay is random on purpose. If it were fixed, players would learn the rhythm and click on a guess, and you would be measuring anticipation instead of reaction.
4. Handle the click, including the early one
function onPress() { const now = performance.now(); if (state === 'idle' || state === 'finished') return begin(); if (state === 'waiting') { // clicked too soon clearTimeout(timer); state = 'idle'; box.className = ''; box.textContent = 'Too soon! Click to try again'; return; } if (state === 'ready') { const ms = Math.round(now - startedAt); times.push(ms); state = 'finished'; box.className = ''; box.textContent = ms + ' ms. Click to go again'; render(); } } box.addEventListener('pointerdown', onPress); box.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPress(); } }); Two details matter here.
Use pointerdown, not click. A click event fires only after the button is released, so it adds the length of the press to every result. pointerdown fires at the moment of contact. It also works for mouse, touch and pen with one handler.
Read the clock first. Notice that performance.now() is the first line of the handler. Anything you do before it, such as updating the DOM, is counted as part of the player's time.
5. Summarize the results
Best, average and median tell different stories. One slow try drags the average up, and the median shrugs it off. This function also throws away invalid entries instead of letting them poison the numbers:
export function summarize(times) { const valid = times.filter(t => Number.isFinite(t) && t > 0); if (!valid.length) return { best: null, average: null, median: null, count: 0 }; const sorted = [...valid].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; const average = valid.reduce((s, t) => s + t, 0) / valid.length; return { best: sorted[0], average: Math.round(average), median: Math.round(median), count: valid.length }; } function render() { const s = summarize(times); out.textContent = 'Tries ' + s.count + ' | Best ' + s.best + ' ms | Average ' + s.average + ' ms | Median ' + s.median + ' ms'; } I ran this function against sample data to check it. For [312, 280, 305, 0, NaN, 260, 410] it returns a best of 260, an average of 313, a median of 305 and a count of 5, so the zero and the NaN are ignored as intended.
6. What the number does not tell you
Be honest with your users about what a browser test measures. Your result includes more than the person's nerves and muscles:
- Display delay. The browser can only paint the green frame on the next screen refresh, so on a 60 Hz display a frame can arrive up to about 16 ms late.
- Input device delay. Mice, trackpads and touchscreens all add their own latency.
- Browser timer precision. Browsers deliberately reduce the resolution of
performance.now()in some contexts to limit fingerprinting, so do not expect microsecond accuracy.
The practical advice: compare a player's results with their own earlier results on the same device, and do not compare across devices. A browser reaction test is good for fun and for rough self-tracking. It is not a medical measurement.
7. Ideas to extend it
- Save the best score in
localStorage, wrapped intry/catchbecause storage can be blocked. - Add a sound cue and compare audio and visual reaction.
- Plot the last ten tries as a small SVG line chart.
- Disable the text selection and the double-tap zoom on mobile with
touch-action: manipulation.
Try the finished version
If you want to see the idea running without writing any code, I built a free, no-signup version here: Reaction Time Test on SaasNm. I run SaasNm, so treat this as my own project, not a neutral recommendation. The code above is a simplified tutorial version and not the full source of that page.
Questions or a bug you found? Leave a comment and I will take a look.