A canvas animation driven by requestAnimationFrame lurches far forward the instant the user switches back to the tab after a minute away. Why does it do that, and how do you keep it smooth?
answer
- hidden pages produce no frames
- the clock kept running, the loop did not
- first delta after resume is enormous
- one step of a minute's motion
- clamp before you integrate
basics
~20 sNo frames are produced for a hidden page, so the loop is suspended. The first callback after the page becomes visible carries a timestamp about a minute past the stored one, and delta-time integration applies that whole gap in a single step. Clamp the delta.
solid answer
~50 sThe browser stops producing frames for a page that is not being rendered, so animation frame callbacks simply stop being invoked while the tab is in the background — the loop is paused, not throttled. Meanwhile the frame timestamp keeps tracking real time, because it is measured from the page's time origin. When the page becomes visible again the loop resumes and the first callback's timestamp is roughly sixty thousand milliseconds past the one you saved, so `speed * delta` advances the animation by a minute's worth of motion in one frame. The fix is to stop trusting the delta: clamp it to a ceiling such as one or two frame intervals, or discard the first frame after a resume by resetting the reference timestamp. The same defence covers the other sources of huge deltas — a long blocking task, a heavy garbage-collection pause, a laptop waking from sleep.
code
javascript · 13 linesconst MAX_DELTA = 32; // about two frames at 60 Hz
let previous = null;
let elapsed = 0;
function tick(now) {
const delta = previous === null ? 0 : Math.min(now - previous, MAX_DELTA);
previous = now;
elapsed += delta;
// advance the animation by `delta` here
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);go deeper
Know that frame callbacks stop entirely while a page is hidden and resume when it is visible again, and that the timestamp keeps advancing meanwhile.
Trace the arithmetic: previous frame stamp versus the first stamp after resume gives a delta of the whole background period, which a per-frame integration applies as one enormous step. Show the clamp and the first-frame guard.
Argue the fix on its merits — clamping is cause-agnostic and covers blocking tasks, GC pauses and sleep, while a visibility listener patches one cause. Distinguish animations that should freeze while hidden from ones that must re-sync to real time, and know the fixed-step accumulator for simulations.
Set the standard that any per-frame integration in the codebase is written against an untrusted delta, and be able to weigh the product question of what should happen to in-flight animation and simulation state when a session resumes after a long gap.
## Two clocks that disagree A delta-time animation keeps two things: a running position, and the timestamp of the previous frame. Each callback computes `delta = now - previous` and integrates. That is the correct pattern, and it is correct precisely because the timestamp tracks real elapsed time rather than counting callbacks. The trouble is that frames are not produced continuously. When a document is not being rendered — a background tab, a minimised window, in some cases an entirely offscreen frame — the browser has no reason to run a rendering update, so animation frame callbacks are not invoked at all. Note the difference from timers: browsers *throttle* timers in background tabs but keep firing them, whereas frame callbacks are simply not called. The loop is suspended. The clock, however, is not suspended. The frame timestamp is milliseconds from the document's time origin, on the same monotonic clock `performance.now()` reads, and it keeps advancing through the whole background period. So on resume: ``` previous = 4200 // last frame before the tab was hidden now = 64200 // first frame after it became visible delta = 60000 // one minute, applied in a single step ``` Anything of the form `position += speed * delta` now teleports. Physics integrations do worse than teleport: a single 60-second step through a collision routine will tunnel straight through geometry, and any integrator with acceleration can produce nonsense velocities it never recovers from. ## The fix, in two parts **Clamp the delta.** Decide the largest step your simulation is willing to take and enforce it: ```js const MAX_DELTA = 32; // about two frames at 60 Hz function tick(now) { const delta = previous === null ? 0 : Math.min(now - previous, MAX_DELTA); previous = now; advance(delta); requestAnimationFrame(tick); } ``` This is one line and it makes the animation robust against every source of a large gap, not just backgrounding. It changes the semantics honestly: while frames are not being produced, animated time does not advance. For a decorative animation that is exactly what you want. **Or reset the reference.** If the animation must reflect real elapsed time — a countdown, a progress indicator tied to a real deadline — do not integrate at all. Recompute the position from a start timestamp and the current one, so a gap resolves to the correct *position* rather than to a giant step. The jump is then correct rather than a bug. For simulations that need both stability and real-time fidelity, the standard compromise is a fixed-step accumulator: add the raw delta to an accumulator, run fixed steps of, say, 16 ms while the accumulator allows, and cap the number of steps per frame so a huge gap cannot turn into thousands of iterations that block the main thread. ## Why not to special-case the tab It is tempting to wire up a visibility listener and reset `previous` when the page comes back. That works for this one cause, but the same enormous delta arrives from a long blocking task on the main thread, a major garbage-collection pause, the machine returning from sleep, or a devtools breakpoint. Clamping is cause-agnostic and needs no event plumbing, which is why it is the right default. A visibility-driven reset is a reasonable *addition* when the animation also needs to re-sync state on resume, not a substitute. ## While you are there: the first frame The same class of bug hides at the start of the loop. If `previous` is initialised to zero or left undefined, the first delta is either the whole time since the page loaded or `NaN`, and the animation starts with the same lurch. Seed `previous` from the first callback's own timestamp, or skip integration on the first frame. ## What good looks like in review Any per-frame integration should have three properties: the first frame cannot produce a garbage delta, an arbitrarily long gap cannot produce an arbitrarily long step, and the loop's own resume path does not depend on knowing *why* frames stopped. Code that has all three is immune to backgrounding without ever mentioning it.
- Would clamping the delta be wrong for a countdown that must stay accurate to real time?Yes, if you integrate. A clamped countdown loses the time the page spent hidden. For anything anchored to real time, do not accumulate deltas at all — derive the value from a start timestamp and the current one, so the resume produces the correct position in one correct jump rather than a wrong incremental step.
- Backgrounded tabs are not the only source of a huge delta. What else produces one?A long blocking task on the main thread, a major garbage-collection pause, a debugger breakpoint, the device waking from sleep, and a window that was minimised or fully occluded. That is exactly why clamping beats a visibility-specific reset: it defends against all of them without needing to know which one happened.
- How would you handle a physics simulation that cannot take a variable-size step at all?Use a fixed-step accumulator: add the frame delta to a running accumulator, then run fixed steps of a chosen size while the accumulator permits, and cap the number of steps per frame. That keeps the integrator stable and prevents a large gap from turning into thousands of iterations that block the frame.
saying these in an interview costs you the question
- Thinks rAF keeps firing at a reduced rate in hidden tabs
- Believes the frame timestamp pauses along with the loop
- Fixes it only with a visibility listener and no clamp
- Leaves the first frame's delta unguarded
- Clamps a real-time countdown and calls it accurate