StopwatchHQ

requestAnimationFrame vs setInterval for Timer Displays

Which one should drive the numbers on a stopwatch? We measured both: the frame rate a browser delivers, the error you get if you count frames as time, and what happens to each when the tab goes to the background. The display should never be the clock.

If you are building a timer in the browser, you will reach the same fork in the road we did: repaint the display with requestAnimationFrame (rAF), or with setInterval? The answer depends on a question people rarely ask out loud — is the repaint loop allowed to be the clock? We measured both loops on Chrome 154 (Linux) to show why it must not be.

The one-line answer

Use requestAnimationFrame to draw and a stored timestamp to tell the time. rAF gives you the smoothest updates while the page is visible, but its timing is not a clock: the frame rate belongs to the display, not the code, and in a hidden tab the loop pauses. setInterval keeps firing in the background but is throttled. Neither is a time source. The elapsed time must always be recomputed from a timestamp; the loop only decides how often you redraw it.

How we measured

We ran an rAF loop for 121 frames in a visible tab and recorded the gap between consecutive calls using performance.now(), the browser's monotonic millisecond clock. (Note: the rAF callback also receives a timestamp argument, but that is the frame's start time and its clock may differ from the page's origin, so we measured our own performance.now() inside each callback.) We then computed the error you would accumulate by treating "number of frames" as time — the classic frame-counting bug. Finally we backgrounded the tab by opening a second tab and activating it, and watched whether the animation loop kept running.

Finding 1: the frame rate belongs to the display, not your code

Displays run at 60, 120 or 144 Hz, and some vary their rate frame to frame. Even at a nominal 60 Hz the frames do not land on a perfect 16.6667 ms grid. Our measured run delivered 121 frames in 1995 ms — an effective 60.2 fps in that environment, with frame gaps clustered at 16–17 ms and one at 10 ms. That wobble is normal, and the 60.2 figure is specific to the machine and to headless mode; the point that matters is that the rate is set by the display you happen to be on, which your code cannot know in advance.

MetricMeasured
Frames captured121
Wall-clock span1995 ms
Median frame gap16.7 ms
Effective frame rate60.2 fps (this environment)

Finding 2: counting frames as time is wrong by more than you think

Suppose a display assumed a clean 60 fps and computed elapsed time as frames × 16.6667 ms. Over our 1995 ms run it would have shown 2000 ms — five milliseconds fast. That sounds tolerable until you change the display: run the same code on a 120 Hz screen and each frame is about 8.33 ms, so counting 120 frames as if they were 60 runs you roughly twice too slow. The five milliseconds is an artifact of a short run on a 60 Hz-class environment; the order-of-magnitude error is what happens whenever your assumption about the rate is wrong. The bug is not the constant you picked. The bug is assuming a constant at all.

AssumptionResult
frames × 16.6667 ms (assume 60 fps) on this 60 Hz-class environment2000 ms vs 1995 ms — 5 ms fast
Same code on a 120 Hz displayabout 2× too slow
performance.now() (the actual clock)1995 ms — correct

Finding 3: in a hidden tab, rAF pauses

When we backgrounded the tab, the animation loop delivered zero frames: our watchdog, waiting four seconds for any frame, fired and recorded a stall. requestAnimationFrame is defined to run only when the page is being rendered, and a hidden tab is not rendered, so in Chrome a purely rAF-driven display pauses the moment the tab leaves the screen. We are careful about the wording: this is what we saw in Chrome by hiding the tab in headless mode, and it is consistent with the documented behaviour — not a universal law across every browser and every kind of occlusion. Meanwhile setInterval in the same conditions keeps firing, but throttled and irregular, and — a caveat worth stating — after a tab has been hidden for several minutes Chrome's "intensive throttling" can cut chained timers to roughly once a minute, which our short run was too brief to reach.

LoopVisibleHidden tab (Chrome)
requestAnimationFrame~60 fps, smoothpauses (0 frames in 4 s)
setIntervalon schedulekeeps firing, throttled to ~1/s

Finding 4: the loop should never be the clock

Put the two together and the architecture chooses itself. The clock is a stored timestamp: elapsed = now − start. The loop — rAF while visible, or a fallback interval where rAF is unavailable — only decides how often that value is drawn. When rAF pauses in a hidden tab, nothing is lost: the next frame, whenever it comes, recomputes the truth. When the interval is throttled, the value is still the truth, just painted less often. In neither case does a missing or late frame corrupt the time, because time was never counted in frames.

Which clock? Both have caveats

It matters which timestamp you store. Date.now() is wall-clock time — the time of day — and it can jump forward or backward when the system clock is corrected or the time zone changes. performance.now() is monotonic — it advances steadily and never jumps — but its behaviour across a laptop suspend has varied between browsers and it is not the time of day. Our rule, and it is a plain one: use performance.now() for measuring an elapsed interval within a session, and a real date/time only for a target the user actually set (a "6:30 am" alarm means 6:30 am, which is Date territory). Neither clock is perfect; the point is to choose the one whose caveat does not bite the thing you are measuring.

How the timers on this site are built

What this means for you

Limitations, stated plainly

Frequently asked questions

Is requestAnimationFrame more accurate than setInterval?

Neither is accurate as a clock — they are delivery mechanisms, not time sources. rAF aligns painting to the display and pauses when hidden; setInterval keeps firing but is throttled. Accuracy comes from recomputing elapsed time from a timestamp at paint time.

Why did my timer freeze when I switched tabs?

Because it was drawn by a rAF loop, which pauses when the tab is not visible (in Chrome). The elapsed time should still have been correct if it was computed from a timestamp; the display simply was not being repainted until you returned.

Should I assume 60fps in my code?

No. Displays run at 60, 120 or 144 Hz and some vary; assuming one rate makes the timer wrong by the ratio between them. Assume nothing about frame timing and read the clock instead.

performance.now() or Date.now()?

Use performance.now() to measure an elapsed interval (it is monotonic and will not jump), and Date when the target is a real time of day the user chose. Each has a caveat; pick the one that does not affect what you are timing.

Related tools and reading

The stopwatch, countdown timer and pomodoro timer all use the rAF-draws, timestamp-counts pattern. For the measurements behind this piece, see what happens to timers in background tabs and how reliable a browser timer is.