You add a "banner fades in over three seconds" effect to a shop's home page. A visitor opens the link in a background tab, comes back ten minutes later, and the banner is still half transparent. An internal dashboard's loading dots spin forever; a blog's "Saved" toast never goes away. No errors anywhere. All three tie work that must finish to a beat that can stop. Ask an agent to fix "the animation doesn't finish" and it usually edits the animation. The animation is fine; the beat stopped.
Why don't animations finish in hidden tabs?
The repaint cycle is not a clock — browsers slow it down or stop it entirely in tabs nobody can see. Browsers pause requestAnimationFrame callbacks in hidden tabs (MDN: requestAnimationFrame), and automated or background windows can drop to one frame per second or exactly half rate (30 fps). Three common patterns all assume the beat keeps coming:
- Progress counted in frames — "raise opacity by 1/180 per frame" finishes only after 180 frames arrive
- Progress counted with a clamped dt — clamping the frame delta (dt) to 0.05 s is a standard trick to keep physics stable. Count an effect timer with that same dt, and in a 1 fps tab each real second counts as 0.05 s: a 5-second effect takes 100 seconds
- Boot code that awaits the beat — awaiting "the next frame" in the loading path means a tab reloaded while hidden never gets that frame and sits on the loading screen forever
There is one more: timers tied to the re-render cycle. In a UI framework, put a "disappear 20 seconds after drawing finishes" timer inside the drawing effect, and every re-render's cleanup clears the timer while the re-run exits early on an "already finished" guard, so the timer is never armed. Here the beat didn't stop; it came so often that it erased the ending.

How do you fix it? Wall clock for endings, timeouts for waits
Work that must finish should measure progress by "seconds since it started," not "times it was called." Let the repaint callback only draw, and recompute where things stand from the wall clock every time. A tab hidden for ten minutes then draws the finished state on its first frame back.
The steps:
- Split beat-driven work in two. Calculations that must not jump (physics, interpolation) and work that must end (effects, fades, boot). Apply dt clamping only to the first
- For work that must end, store the start time and compute progress as
(now − start) ÷ duration. Do not accumulate per-frame increments - Put a timeout on every wait in the boot path. Race "next frame" waits and async steps like shader compilation against a
setTimeout(Promise.race) and move on if they don't arrive in a few seconds.setTimeoutstill fires in hidden tabs, just throttled to around a second (MDN: setTimeout) - Attach "after N seconds" timers to the lifetime of the work, not to drawing. Keep them out of code that is cleaned up on every re-render; clean them up once, when the component goes away
- Verify in a genuinely hidden tab. A tab with developer tools open counts as visible and won't reproduce the bug. Close the tools, push the window to the back, reload, then return. Log visibility with
document.visibilityStatefrom the Page Visibility API
A rule to put in your agent's instructions
When an effect, loader or toast "never finishes," suspect the beat before the effect code.
1) Find whether the work advances by frame count, a clamped dt, or awaiting the next frame.
2) For work that must end, store the start time and compute progress as (now - start) / duration.
3) Race every wait in the boot path against a setTimeout so it has an upper bound.
4) If an "after N seconds" timer lives where every re-render cleans it up,
move it to the lifetime of the work.
5) Reproduce and verify with developer tools closed and the tab hidden during reload.
What happened to us
These came from two different graphics projects.
- A 5-second effect took 100 seconds — a brush-reveal effect never finished in a hidden tab. The effect timer used the dt clamped to 0.05 s for physics, so at one frame per second it ran 20 times slow
- A loader stuck on a pink dot — the boot path awaited the next frame, so a tab reloaded while the user looked at another window stayed on the loading screen forever. It did not reproduce with developer tools open. The shader-compile wait in the same path got a timeout too
- Calligraphy that never faded — a "fade 20 seconds after finishing" timer lived inside the drawing effect; each re-render's cleanup cleared it and the re-run exited on an "already done" guard
- Blank screens in automated browsers — for the same reason, a 3D scene in automated or background tabs stayed blank with fps reading exactly 30 or about 1, looking like broken code