JavaScript Page Visibility Table
| Piece | What it does | Field note |
|---|---|---|
document.visibilityState | The state read | 'visible' or 'hidden' - read explicitly; the boolean shorthand invites inverted logic |
visibilitychange (document) | The switch event | Fires on tab/app switch, minimize, screen lock - one listener, two branches |
pause on hidden | Stop time-based work | Polling, video, animation loops - the platform clamps them anyway; do it with intent |
resync on visible | Recover fresh | Re-fetch once and reset timers - never trust accumulated ticks across a background gap |
background throttling | The platform rules | Timers clamp to 1/sec (chains to 1/min), rAF stops entirely - workers have separate budgets |
prerender branch | The invisible load | Speculation-prerendered pages run code before being seen - defer non-deferrable work to activation |
hidden-time analytics | Honest dwell | Sum visible spans, not wall clock - background tabs inflate time-on-page by minutes |
beacon on hidden | The last reliable moment | sendBeacon/keepalive on transition to hidden - beforeunload is unreliable on mobile and bfcache-hostile |
Page Visibility tells you whether the user can actually SEE your tab: document.visibilityState is 'visible' or 'hidden', and visibilitychange fires on every switch - tab change, app switch, minimize, screen lock. It is the primitive behind pause-on-hide video, stop-polling-when-hidden dashboards, and games that freeze instead of accumulating input while unattended.
Bottom line: the browser throttles background tabs anyway - timers clamp to once per second, rAF stops entirely - so your hidden-state code is not optional politeness, it is catching up with what the platform already does. The discipline: on hidden, pause everything time-based (timers, polling, video, animation loops); on visible, resync from the source of truth instead of trusting accumulated ticks.
The honest part: document.hidden lies in exactly one case that matters - on mobile, the app switcher can keep your page technically visible while the user is gone, and Picture-in-Picture or screen-off behaviors vary by platform. Treat visibilityState as a strong hint, not a guarantee, and anchor anything money- or time-critical to server timestamps instead of client-visible time.
How to use
- React to switches: document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') pauseAll(); else resumeAll(); }) - one listener, two branches, the entire pattern.
- Pause time-based work on hidden: clearInterval your polling, video.pause() your playback, stop your rAF loop - resuming them while hidden only gets you clamped timers and burned battery.
- Resync on visible instead of accumulating: on return, re-fetch the dashboard once and reset local timers - a 20-minute backgrounded tab that trusted setInterval ticks is 20 minutes of stale data wearing a live UI.
- Log honest sessions: track hidden timestamps for analytics - time-on-page that counts background tabs overstates engagement by minutes; visibility-aware timing is the real dwell time.
- Mind the exceptions: PiP windows and app switchers may keep visibilityState = 'visible' - for money-critical timing, anchor to server clocks and never bill by client-visible time alone.
Frequently asked questions
What is the difference between document.hidden and visibilityState?
visibilityState is the full state ('visible', 'hidden', and historically 'prerender'/'unloaded' before those phases moved to dedicated APIs); hidden is the boolean shorthand for hidden === 'visible' being false. Modern code should read visibilityState and compare explicitly - hidden reads as a negation that gets misspelled into inverted logic surprisingly often, and prerender (speculation-prerendered pages) deserves its own branch: your page may be loaded and RUNNING before the user ever sees it, which is why prerendered pages defer non-deferrable work until activation.
What exactly do browsers throttle in background tabs?
The clamp list grew over the years: timers (setInterval/setTimeout) run at most once per second after ~5 seconds hidden (intensive throttling can push that to once per minute for chained timers on inactive pages), requestAnimationFrame stops entirely, and some budgets hit network-priority and scheduler queues. The consequence: your background music scheduler, animated countdown, or 10-second poll all silently degrade - which is the point. Code that must run while hidden (alarm while the tab is backgrounded) needs a Web Worker (its own timer budget) or a Service Worker push, not a heroic setInterval.
Should I pause video and games when the tab hides?
Yes for games (freeze the loop, accumulate nothing - rAF stopping already freezes rendering, but your physics must not simulate 600 catch-up frames on return), and yes for video by convention: every major player pauses on hidden, and browsers may do it for you with media-session behaviors - doing it yourself keeps the UX consistent and your analytics honest. The subtle part is resume: re-fetch ad schedules or stream health, re-sync subtitles, and let the user resume where they were - pause state, not play state.
How does visibility relate to beforeunload and session analytics?
visibilitychange to 'hidden' is the modern de-facto LAST RELIABLE MOMENT to send session-end beacons (fetch keepalive or sendBeacon) - beforeunload and unload are unreliable on mobile and being deprecated for bfcache reasons. The honest dwell metric: sum of visible-time spans, not wall clock since load. A tab open in the background for two hours reports zero additional dwell under visibility-aware accounting - and that correction is usually the single biggest fix to engagement dashboards that never made sense.