A timer that adds one per interval assumes every callback runs on time. Browsers do not promise that: a busy main thread delays callbacks, and background tabs throttle them to once a second or less. A typing score built on a callback count would divide by too little time and inflate WPM. Typestrix derives elapsed time from clock readings instead, and uses the interval only to refresh the screen.
Ticks refresh, anchors measure
anchorRef.current = Date.now();
const tick = () => {
const elapsed = baseElapsedRef.current + (Date.now() - anchorRef.current) / 1000;
const secs = Math.floor(elapsed);
setTimePassed(mode === "time" && secs >= modeOption ? modeOption : secs);
};
const timer = setInterval(tick, 250);
Every tick recomputes elapsed time from the clock. If a tick arrives two seconds late, the display jumps two seconds ahead instead of pretending only a quarter-second passed. The number of ticks that ran never enters the calculation.
The interval is 250 ms rather than 1 second so the visible counter turns over within a quarter-second of the real boundary. setTimePassed with an unchanged value bails out in React, so three of every four ticks cost nothing.
Active spans add up
Pick a step, or use the arrows, to walk through the diagram.
The timer is active only while raceStarted && timeStarted && !cheatDetected. Guests (no integrity enforcement) never see the focus warning, so for them the clock simply runs.
Reset has to wait for cleanup
Zeroing the base inside resetTimer() is not enough. React runs the old effect's cleanup after the reset, and that cleanup adds the last active span back into the freshly zeroed base. The next test would then start a few seconds in.
Pick a step, or use the arrows, to walk through the diagram.
One completion time for one result
completeTest() copies timePassedRef.current into completedAtTimeRef and computes WPM, raw WPM, accuracy and the final chart point with that value. The result handed to onComplete (used by multiplayer) is built later, in an effect after the completion render, and reads completedAtTimeRef, not the live timer. The solo result screen shows and saves timePassed directly; that is safe because completion also makes the timer inactive, which clears its interval, so the value cannot move afterwards.
isCompletingRef also makes completion one-shot, so a time-mode deadline and a final character arriving together cannot calculate the result twice.
What this does not protect against
- Clock changes.
Date.now()follows the system clock. If it jumps (NTP sync, manual change) mid-test, elapsed time jumps too.performance.now()is monotonic and would avoid that. - A modified client. Duration is measured in the browser. The server checks that a result is plausible, not that it is true.
- Delayed rendering. A frozen main thread still freezes the display; it just no longer loses the time.
The lesson generalises beyond typing: a recurring callback is a scheduling tool, not a clock. Measure time from a clock, use callbacks only to refresh what people see, and decide carefully when the old measurement ends and the new one begins.