Page Visibility API to pause work in a backgrounded tab
Published May 25, 2027
export const pauseWhenHidden = (start: () => () => void) => {
let stop = start();
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
stop();
} else {
stop = start();
}
});
};What
pauseWhenHidden(start) runs start() to begin some ongoing work — polling, a countdown, a canvas animation loop — and calls whatever cleanup function start() returns whenever document.visibilityState becomes "hidden", then calls start() again when the tab is visible once more.
Why it matters
A background tab doesn't stop your JavaScript by itself. setInterval keeps firing (throttled, but not stopped), WebSocket pings keep going out, and a requestAnimationFrame loop for a tab the user can't see keeps burning CPU and battery for zero visible benefit. Browsers throttle some of this automatically, but throttled isn't the same as paused — a poll that should stop entirely while nobody's looking still fires, just less often.
The Page Visibility API gives a direct, reliable signal for "the user genuinely cannot see this page right now" — more precise than guessing from blur/focus, which fire for reasons that don't actually mean the tab is hidden (like clicking into DevTools).
How it works
startis a function that begins the work and returns its own teardown — the same shape as auseEffectcallback, chosen so this helper works for any kind of ongoing task without knowing what it is.pauseWhenHiddencallsstart()immediately and keeps the returned stop function instop.- On every
visibilitychangeevent, it checksdocument.visibilityState."hidden"calls the currentstop()to tear down the running work; anything else ("visible") callsstart()again and captures its new stop function — because whatever was torn down needs a fresh instance, not a resume of the old one.
Gotchas
visibilitychangefires for tab switches, minimizing the window, and switching virtual desktops/spaces — but not for the window merely losing focus while still on screen (e.g. clicking into another app in a visible, tiled window). Don't treat this as a focus signal.- There's no guarantee
visibilitychangefires before a tab is discarded or the browser closes outright — for work that must be flushed no matter what, also listen forpagehide, which fires more reliably at true teardown. - Calling
start()again on every visible transition means it's responsible for its own idempotency — ifstartcan be called while its own previous instance never got torn down (a missed event, a bug elsewhere), make sure it doesn't double up (e.g. clear a previous interval ID before creating a new one) rather than assuming exactly onestop/startpair per transition.
Related
requestAnimationFrame— already pauses itself in most browsers for a hidden tab, but a manualpauseWhenHiddenstill helps when you want to release resources (a WebGL context, a media stream) rather than just stop scheduling frames.- The Web Locks API — a different "is this tab special right now" concern: which tab should act, rather than whether this tab should act at all.