Build a Stopwatch / Timer
Track elapsed time from timestamps (not by counting ticks), drive UI updates with an interval or requestAnimationFrame, support start/pause/resume/reset/lap, clean up the timer on unmount, and stay accurate when the tab is backgrounded by computing elapsed = now - startTime.
A stopwatch looks trivial but has a sharp accuracy trap. The key principle: derive elapsed time from timestamps, never accumulate it by counting ticks.
The accuracy trap
The naive approach — setInterval(() => setSeconds(s => s + 1), 1000) — drifts. setInterval isn't precise, it's throttled when the tab is backgrounded, and errors accumulate. After a few minutes in a background tab, it's noticeably wrong.
Correct approach: store the start timestamp, and on each tick compute elapsed = Date.now() - startTime. The interval only drives re-renders; the value comes from real timestamps. Even if ticks are missed or delayed, the displayed time is always correct.
State & implementation
function Stopwatch() {
const [elapsed, setElapsed] = useState(0); // ms
const [running, setRunning] = useState(false);
const startRef = useRef(0); // when current run started
const accumulatedRef = useRef(0); // ms from previous runs (pause/resume)
const intervalRef = useRef(null);
const start = () => {
startRef.current = Date.now();
intervalRef.current = setInterval(() => {
setElapsed(accumulatedRef.current + (Date.now() - startRef.current));
}, 50); // 50ms interval => smooth display, value still from timestamps
setRunning(true);
};
const pause = () => {
clearInterval(intervalRef.current);
accumulatedRef.current += Date.now() - startRef.current; // bank the run
setRunning(false);
};
const reset = () => {
clearInterval(intervalRef.current);
accumulatedRef.current = 0;
setElapsed(0);
setRunning(false);
};
useEffect(() => () => clearInterval(intervalRef.current), []); // cleanup on unmount
// ...render formatted elapsed, buttons
}Features to cover
- Start / Pause / Resume —
accumulatedRefbanks completed runs so resume continues correctly. - Reset — clear interval + accumulated + elapsed.
- Lap — push the current elapsed onto a laps array.
- Formatting — ms →
mm:ss.cs.
Key correctness points
- Cleanup —
clearIntervalon unmount and on every stop, or you leak timers / setState on an unmounted component. - Timestamps, not tick-counting — accuracy survives background throttling.
useReffor the timer id and start/accumulated values — they shouldn't trigger re-renders; onlyelapsedshould.- For sub-frame smoothness use
requestAnimationFrameinstead ofsetInterval(rAF pauses in background tabs — fine, since the value is timestamp-derived and corrects on return). performance.now()overDate.now()if you want monotonic time unaffected by clock changes.
How to answer
"The trick is accuracy: don't count ticks with setInterval — it drifts and gets throttled in background tabs. Store the start timestamp and compute elapsed = now - start on each tick; the interval just drives re-renders. Use refs for the timer id and accumulated time so they don't cause renders, bank elapsed on pause for correct resume, and always clear the interval on unmount."
Follow-up questions
- •Why does counting ticks with setInterval drift?
- •How do you keep the stopwatch accurate when the tab is backgrounded?
- •Why use useRef for the timer id and start time instead of state?
- •When would you use requestAnimationFrame instead of setInterval?
Common mistakes
- •Counting ticks (s => s + 1) instead of computing elapsed from timestamps — drifts.
- •Not clearing the interval on unmount — timer leak / setState on unmounted component.
- •Storing the timer id in state, causing extra re-renders.
- •Pause/resume that loses or double-counts elapsed time.
Performance considerations
- •A ~50ms interval gives a smooth display without excessive renders. requestAnimationFrame aligns with paint and self-throttles in background tabs. Refs avoid re-rendering on timer-id/start-time changes.
Edge cases
- •Tab backgrounded for a long time, then resumed.
- •Rapid start/pause clicking.
- •System clock changing (use performance.now()).
- •Component unmounting while running.
Real-world examples
- •A workout timer, a chess clock, a task time-tracker.