10LOC
Web Platformintermediate

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

  • start is a function that begins the work and returns its own teardown — the same shape as a useEffect callback, chosen so this helper works for any kind of ongoing task without knowing what it is.
  • pauseWhenHidden calls start() immediately and keeps the returned stop function in stop.
  • On every visibilitychange event, it checks document.visibilityState. "hidden" calls the current stop() to tear down the running work; anything else ("visible") calls start() again and captures its new stop function — because whatever was torn down needs a fresh instance, not a resume of the old one.

Gotchas

  • visibilitychange fires 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 visibilitychange fires before a tab is discarded or the browser closes outright — for work that must be flushed no matter what, also listen for pagehide, which fires more reliably at true teardown.
  • Calling start() again on every visible transition means it's responsible for its own idempotency — if start can 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 one stop/start pair per transition.

Related

  • requestAnimationFrame — already pauses itself in most browsers for a hidden tab, but a manual pauseWhenHidden still 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.