Stopwatch accuracy, and why browser timers drift
Why setInterval is the wrong way to build a timer, how background tabs are throttled, and what accuracy a browser stopwatch can actually deliver.
Last reviewed · 1,260 words
In short
- A timer built by counting setInterval ticks drifts, because each tick fires late and the lateness accumulates.
- The correct approach records a start timestamp and computes elapsed time on each frame, so drift cannot accumulate.
- Background tabs are throttled to roughly one tick per second, and heavily throttled after five minutes.
- performance.now() is monotonic and immune to clock changes; Date.now() is not, and a system clock sync can move it backwards.
- Human reaction time is 200 to 250 milliseconds, which exceeds any timing error a well-built browser stopwatch introduces.
A stopwatch in a browser looks trivial and is a good example of a problem where the obvious implementation is wrong in a way that only shows up after several minutes.
Why counting ticks drifts
The intuitive approach is to run setInterval every 10 milliseconds and add 10 to a counter each time.
It drifts, and it drifts in one direction.
setInterval guarantees a minimum delay, not an exact one. The callback fires when the main thread is free, which is at least 10 ms later and frequently more. Each late tick adds its lateness to the accumulated total, and the errors only ever push the count behind real time.
Over an hour, a tick-counting timer can lose several seconds. Over a day it is minutes. Every such timer is slow, never fast, because a callback cannot fire early.
The correct approach
Record the start time once, and on every update compute the difference from now:
start = performance.now()
// on each frame:
elapsed = performance.now() - start
The displayed value is derived from the clock rather than accumulated, so a late frame produces a slightly jumpy display and no drift at all. The next frame is correct regardless of how late the previous one was.
Updates are driven by requestAnimationFrame, which fires once per screen refresh — usually 60 times a second — and pauses automatically when the tab is hidden. That pause does not matter, because the elapsed time is recomputed from the clock when the tab returns.
Which clock
Two are available and they behave differently.
Date.now() returns wall clock time in milliseconds since 1970. It is not monotonic: an NTP synchronisation, a manual clock change or a daylight saving transition can move it, including backwards. A stopwatch using it can show negative elapsed time.
performance.now() returns milliseconds since the page loaded, from a monotonic source that only moves forward. It is immune to clock adjustments and has sub-millisecond resolution in principle.
Use performance.now() for durations, always. Use Date.now() only for absolute timestamps.
One caveat: browsers deliberately reduce the resolution of performance.now() — typically to 0.1 ms, and coarser in some configurations — as a defence against timing attacks such as Spectre. That is far finer than any human timing need.
Background tab throttling
Browsers throttle timers in hidden tabs to save battery, and the throttling is aggressive.
| State | Timer behaviour |
|---|---|
| Visible tab | Full rate; requestAnimationFrame at screen refresh |
| Hidden tab | Timers limited to roughly once per second |
| Hidden over 5 minutes | Throttled to roughly once per minute |
| Heavily backgrounded | May be frozen entirely |
For a correctly built stopwatch this changes only the display. The elapsed time is computed from the clock, so switching back shows the right value immediately, however long the tab was hidden.
For a tick-counting stopwatch it is catastrophic. Switch away for ten minutes and it counts ten seconds.
This is the clearest practical demonstration of why the two implementations are not equivalent, and it is easy to test: start a stopwatch, switch tabs for two minutes, and compare it against a phone.
What accuracy is achievable
A well-built browser stopwatch is accurate to within a few milliseconds over any duration, because it reads a monotonic clock rather than accumulating.
The display updates at the screen refresh rate, so the shown value can be up to about 16 ms behind on a 60 Hz screen. That is a rendering delay, not a measurement error — the underlying value is correct.
The limiting factor is not the software:
| Source | Typical magnitude |
|---|---|
| Human reaction time | 200–250 ms |
| Display refresh | 8–16 ms |
| Input event latency | 5–20 ms |
| Clock resolution | 0.1 ms |
Human reaction dominates by a factor of ten or more. Anyone starting and stopping a browser stopwatch by hand introduces far more error than the browser does, which is why hand timing in athletics is conventionally recorded to a tenth of a second while electronic timing goes to a thousandth.
For anything where the timing genuinely matters — a race result, a scientific measurement — the answer is hardware triggering, not a better stopwatch.
Lap and split
Two related measurements that are frequently confused.
Lap time is the duration of each segment individually. Four laps of 60, 62, 61 and 59 seconds.
Split time is the cumulative elapsed time at each mark. The same four laps give splits of 60, 122, 183 and 242 seconds.
Lap times answer "how fast was that section". Splits answer "am I on pace". Runners generally want splits during a race and laps afterwards.
A stopwatch should record both, since each is derivable from the other and having them presented separately saves arithmetic at exactly the moment nobody wants to do arithmetic.
Practical use
Interval training. Work and rest periods, where a countdown is usually better than a stopwatch because it does not require watching.
Cooking, where a countdown with an alarm is the right tool.
Billing time, where the requirement is a record rather than precision, and a running total across sessions matters more than the stopwatch itself.
Pacing a talk, where a large visible display and a discreet warning are what is actually needed.
Anything competitive should not use a browser stopwatch, for the reaction time reason above.
Keep the tab visible where the display matters. The value stays correct either way, and a throttled tab will not update the screen.
Building one correctly
For anyone implementing this, the pattern is short and the details are where it goes wrong.
State. Keep the start timestamp and an accumulated total for previous runs. Pausing adds the current run to the accumulated total and clears the start; resuming sets a new start.
Do not store the displayed value as state. It is derived, and storing it is what reintroduces drift.
Update with requestAnimationFrame, not setInterval. It matches the screen
refresh, it pauses automatically when hidden, and it costs nothing to resume.
Format on display only. Converting milliseconds to hours, minutes, seconds and hundredths is presentation, and doing it in the state layer invites rounding errors to accumulate.
Handle the tab returning. Nothing special is needed if the value is computed from the clock — which is the point of building it that way.
Persist across reloads by storing the start timestamp rather than the elapsed time. A stopwatch that survives an accidental refresh is meaningfully better and costs one line.
Hundredths, and what a display should show
Showing more precision than the measurement supports is misleading, and stopwatch displays routinely do it.
Hundredths of a second is the conventional resolution and is already finer than a human can start or stop. It is worth showing because the last digits move visibly, which tells the user the timer is running.
Thousandths are noise for hand timing. The reaction error is two hundred times larger.
Above an hour, hundredths become clutter and the useful display is hours, minutes and seconds.
The convention that reads best is to show only the units in use — 12.45 rather
than 00:00:12.45 — and to add each larger unit as it becomes non-zero. A
stopwatch cluttered with leading zeros is harder to read at a glance, which is the
one thing it exists to support.
What this tool assumes
- Elapsed time is computed from a monotonic clock on each frame, not accumulated from tick counts, so it does not drift.
- The display updates at the screen refresh rate and pauses when the tab is hidden; the underlying value continues correctly.
- Accuracy is limited by your reaction time, not by the timer.
- Nothing is transmitted. Timing happens entirely in your browser.
- For results that matter competitively or scientifically, use equipment designed for it.