Build a stopwatch with start, pause, reset, and millisecond precision
Track a startedAt timestamp + accumulated ms across pauses; compute display from Date.now(). Use requestAnimationFrame (or setInterval) just to trigger re-renders. Don't store ticks in state.
The trap: the obvious "increment a counter every 10ms" implementation drifts (setInterval is not precise) and bloats state. The right architecture is timestamp-based: store when the stopwatch started and how much time was already accumulated; compute the display value from Date.now() on each render.
Why timestamp, not tick counting.
setInterval(fn, 10) is not guaranteed to fire every 10ms. Background tabs throttle it to 1Hz, slow frames delay it, and incrementing elapsed += 10 accumulates drift. With timestamps, you read the wall clock — drift-free.
State shape.
type State =
| { running: false; elapsedMs: number } // paused or initial
| { running: true; startedAt: number; baseMs: number }; // baseMs = elapsed before this runWhen running, elapsed = baseMs + (Date.now() - startedAt). Pause folds the running delta into baseMs and switches to paused. Reset zeros everything.
Re-render driver. A separate effect schedules requestAnimationFrame while running is true. Each frame calls forceUpdate (or sets a now state). The display formats elapsed() from current state + now. requestAnimationFrame is preferred over setInterval(_, 16) because it pauses when the tab is hidden (no wasted work) and aligns with paint.
Format helper. Convert ms to mm:ss.cc (centiseconds) or hh:mm:ss.mmm. Use String(n).padStart(2, "0").
Cleanup. The rAF effect must cancel its scheduled frame on dependency change / unmount, otherwise paused stopwatches keep firing rAF callbacks (cheap, but wasteful).
Variants interviewers ask for.
- Lap times. Each "Lap" press snapshots current elapsed into a list. Show diff from previous lap.
- Countdown timer. Same model in reverse:
remaining = totalMs - elapsed(). Stop and fireonCompletewhen ≤ 0. - Multiple stopwatches. Hoist the model into a custom hook
useStopwatch()so each instance is independent.
Avoid these. (1) Storing the displayed time in state and incrementing it — drift + unnecessary renders. (2) Forgetting to cancel rAF on pause — battery drain. (3) Recreating the stopwatch object on every render — pause/start lose state. (4) Using Date.now() in the dependency array — infinite loop.
Code
Follow-up questions
- •How would you add lap times?
- •How would you turn this into a countdown timer?
- •Why prefer requestAnimationFrame over setInterval?
- •What happens when the user backgrounds the tab?
Common mistakes
- •Storing the elapsed time in state and incrementing it — drift over minutes.
- •Forgetting to cancel rAF on pause / unmount.
- •Resetting baseMs to 0 on pause — restart should resume, not start over.
- •Putting Date.now() in a useEffect dependency array — infinite renders.
Performance considerations
- •rAF auto-pauses on hidden tabs — better than setInterval.
- •Don't re-render at 60fps if you only display seconds — throttle to 1Hz with setInterval(_, 1000).
Edge cases
- •Backgrounded tab: rAF stops; on resume, elapsed jumps to the correct wall-clock value (correct behavior).
- •System clock change mid-run — Date.now() jumps; use performance.now() for monotonic time.
- •Pause then quickly resume — make sure baseMs is captured before flipping running.
Real-world examples
- •Toggl, Pomofocus, browser stopwatch widgets — all use timestamp-based models.