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.
| Metric | Measured |
|---|---|
| Frames captured | 121 |
| Wall-clock span | 1995 ms |
| Median frame gap | 16.7 ms |
| Effective frame rate | 60.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.
| Assumption | Result |
|---|---|
| frames × 16.6667 ms (assume 60 fps) on this 60 Hz-class environment | 2000 ms vs 1995 ms — 5 ms fast |
| Same code on a 120 Hz display | about 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.
| Loop | Visible | Hidden tab (Chrome) |
|---|---|---|
| requestAnimationFrame | ~60 fps, smooth | pauses (0 frames in 4 s) |
| setInterval | on schedule | keeps 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
- rAF drives the painting for smooth, vsync-aligned updates that cost nothing when the tab is hidden.
- A timestamp is the clock. Elapsed time is recomputed from a stored start using
performance.now(), which is monotonic and does not jump when the system clock is adjusted. - An interval is the fallback, used only where rAF is unavailable, and it still only drives painting, never the value.
What this means for you
- If you are writing a timer: draw with rAF, but compute time from a timestamp. Do not increment a frame counter and call it seconds — on a high-refresh display it is wrong by a factor, not a fraction.
- If you are choosing a timer to use: the ones here — the stopwatch, timer and interval timer — keep correct time even when the tab is hidden, because the number comes from the clock, not the frame.
- If a web timer has ever frozen when you switched tabs and jumped when you came back, you now know why: it was painting from a loop that had paused, or counting frames that never arrived.
Limitations, stated plainly
- One platform, one run, headless. Every number is a single run of Chrome 154 on one Linux desktop in headless mode. Headless Chrome has no physical display — its frames come from an internal timer, not a monitor's refresh — so the "60.2 fps" describes that environment, not your screen. There is no variance data here; read this as a clear illustration of how the loops behave, not a benchmark across hardware.
- Not tested on other browsers or real monitors. Firefox and Safari schedule rAF differently, and a 120 Hz or variable-refresh monitor would change the frame-rate numbers. We did not measure them.
- The freeze is a feature, not a bug. rAF pausing in a hidden tab is by design — it saves battery. The bug is only in using it as a clock.
- Short run. Our test did not run long enough to reach Chrome's intensive throttling of chained timers (roughly once a minute after several minutes hidden); that behaviour is documented, not measured here.
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.