../

Page Visibility & lifecycle

document.visibilityState and visibilitychange, the Page Lifecycle states (with Chromium's freeze/resume), pagehide/pageshow and the back/forward cache, why unload is dead, and how to send data on exit and schedule idle work. Support data is from MDN as of September 2026.

Support

FeatureStatus (MDN)Notes
visibilityState, hidden, visibilitychangeBaseline widely availableevery browser
pagehide / pageshow, event.persistedwidely supportedthe bfcache-aware exit/entry events
navigator.sendBeacon()Baseline widely availableChrome rejects non-safelisted Blob types
fetch(..., { keepalive: true })all enginesFirefox 133+
beforeunloadnot Baselinenever fires on iOS Safari
Page Lifecycle (freeze, resume, wasDiscarded)Chromium onlyexperimental
requestIdleCallback()not BaselineChromium, Firefox; not Safari
scheduler.yield(), postTask()not BaselineChromium 129+, Firefox 142+
fetchLater()Chromium 135+deferred beacon, experimental
notRestoredReasonsChromium 125+why bfcache wasn't used

Page Visibility API

MemberTypeNotes
document.visibilityState"visible" | "hidden""prerender" is gone from the spec
document.hiddenbooleansame as visibilityState === "hidden"
visibilitychangeEvent on documentread visibilityState inside the handler

hidden means: another tab is selected, the window is minimized, the screen is locked, the user switched apps on mobile, or the page is being unloaded. A window merely covered by another window usually still counts as visible.

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") {
    // save state, flush analytics, pause work
  } else {
    // resume, refresh stale data
  }
});

Page Lifecycle states

             focus / blur
  ACTIVE  <---------------->  PASSIVE
  visible, focused            visible, not focused
                                 |    ^
          visibilitychange       |    |  visibilitychange
          (hidden)               v    |  (visible)
                              HIDDEN ------------------> TERMINATED
                                 |    ^    pagehide         (page gone)
                         freeze  |    |  resume
                                 v    |
                              FROZEN  --- memory pressure --> DISCARDED
                     (bfcache or      (no event; wasDiscarded
                      background tab)  is true on reload)
StateVisibleFocusedJS runsEntered via
activeyesyesyesload, focus
passiveyesnoyesblur (another window or iframe)
hiddennonothrottledvisibilitychange
frozennonono tasksfreeze (Chromium), entering bfcache
terminatednononopagehide without bfcache
discardednonopage unloadednothing fires

Leaving a page that goes into the bfcache fires visibilitychange (hidden), then pagehide with persisted: true, then freeze. Coming back fires resume, pageshow with persisted: true, then visibilitychange (visible).

freeze & resume

Chromium freezes hidden background tabs to save CPU and battery, and every browser freezes pages in the bfcache. Timers, fetch callbacks and rAF stop until the page resumes. Neither event is in lib.dom, so the listeners take plain Event.

document.addEventListener("freeze", () => {
  // close IndexedDB and connections; no async work
});
document.addEventListener("resume", () => {
  // reopen connections, refresh stale data
});
 
// Chromium: this load replaces a discarded tab
const wasDiscarded =
  (document as { wasDiscarded?: boolean }).wasDiscarded;

freeze handlers get a few milliseconds and can't start async work that needs to finish. Put anything important in the visibilitychange handler instead, which fires first.

pagehide, pageshow & bfcache

The back/forward cache keeps a frozen snapshot of the page so Back and Forward restore it instantly, JS heap and all. load does not fire again on a restore.

Eventevent.persistedUse for
pageshowtrue = restored from bfcacherefresh stale data, reconnect, re-check auth
pagehidetrue = page may enter the bfcacheclose connections, last-chance save on desktop
Blocks bfcacheFix
an unload listener (desktop Chrome, Firefox)use pagehide or visibilitychange
Cache-Control: no-store on the HTMLonly for truly sensitive pages (Chrome is relaxing this)
open IndexedDB transaction, in-flight fetch/XHRclose or finish on pagehide
open WebSocket or WebRTC connectionclose on pagehide (Chrome 149+ and Safari allow open WebSockets)
window.opener referencerel="noopener" on links, noopener in window.open

Test in Chrome DevTools: Application → Back/forward cache → Test. In the field, read notRestoredReasons from the navigation entry (Chromium 125+).

type NavWithReasons = PerformanceNavigationTiming & {
  notRestoredReasons?: unknown; // not in lib.dom yet
};
const [nav] = performance.getEntriesByType(
  "navigation",
) as NavWithReasons[];
if (nav?.type === "back_forward") {
  console.log(nav.notRestoredReasons);
}

Why not unload & beforeunload

EventProblemUse instead
unloadunreliable on mobile, blocks bfcache; Chrome is disabling it by default, a rollout that reaches all page loads around Chrome 154 (September 2026)visibilitychange, pagehide
beforeunloadonly for "unsaved changes" prompts; never fires on iOS Safari; unreliable elsewheresave continuously; visibilitychange
  • A beforeunload dialog shows only if the user has interacted with the page, and its text is always the browser's own; your message is ignored.
  • Add the beforeunload listener only while there are unsaved changes, and remove it after saving.
  • Disable unload today with the header Permissions-Policy: unload=(); this also catches third-party scripts that still add it.

Sending data on exit

MethodMethod / bodyLimitNotes
navigator.sendBeacon(url, data)POST only64 KiB in flightreturns false if not queued; no response
fetch(url, { keepalive: true })any method and headers64 KiB in flight (shared)outlives the page; response usually lost
fetchLater(url, init)any; activateAfter msquota per originChromium only; the browser sends it at unload or after the delay
sendBeacon bodyContent-Type sent
stringtext/plain;charset=UTF-8
URLSearchParamsapplication/x-www-form-urlencoded
FormDatamultipart/form-data
Blobits type; Chrome throws for non-safelisted types such as application/json

Send JSON as a string (text/plain) and parse it on the server, or use fetch with keepalive when you need application/json or an auth header. See Fetch API.

declare const events: object[];
 
// beacon: simplest, text/plain body
navigator.sendBeacon("/rum", JSON.stringify(events));
 
// keepalive fetch: real headers
void fetch("/rum", {
  method: "POST",
  keepalive: true,
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(events),
});

Idle work & scheduling

APIRunsSupport
requestIdleCallback(cb, { timeout })in idle time; deadline.timeRemaining() at most 50 msChromium, Firefox
scheduler.postTask(cb, { priority })"user-blocking", "user-visible", "background"Chromium, Firefox 142+
scheduler.yield()resumes soon, ahead of other queued tasksChromium 129+, Firefox 142+
setTimeout(cb, 0)next task; the universal fallbackeverywhere
function whenIdle(cb: () => void, timeout = 2_000) {
  if (typeof requestIdleCallback === "function") {
    requestIdleCallback(cb, { timeout });
  } else {
    setTimeout(cb, 1); // Safari
  }
}

Idle callbacks don't run in hidden or frozen pages until the page comes back (or timeout expires), so never rely on them for data you must send before the page goes away. More on the event loop and task queues in Async & promises.

Pausing work when hidden

WorkWhat the browser does when hiddenWhat you should do
requestAnimationFramepausednothing; rAF loops stop for free
setTimeout/setIntervalthrottled to at most once per second; Chrome batches chained timers to once per minute after 5 minutes hiddenstop polling; refetch on visible
fetch pollingstill runs (slowly)pause, then refresh once on return
WebSocketstays open until frozen or killedreconnect on pageshow/resume
<video> / animationsvideo keeps decoding unless pausedpause media the user can't see
audio playingexempt from most throttlingfine for music and calls

TanStack Query and SWR already pause and refetch on visibility changes; see TanStack Query.

Typing in TS

SituationFix
visibilityStateDocumentVisibilityState: "visible" | "hidden"
pageshow / pagehide listeners on windowe is PageTransitionEvent with persisted
freeze / resume on documentnot in DocumentEventMap; the handler gets a plain Event
document.wasDiscarded, fetchLater, notRestoredReasonsnot in lib.dom; widen the type locally
requestIdleCallbacktyped as always present; test typeof for Safari
schedulertyped in TypeScript 7's lib.dom, missing in 5.x

Recipes

Pause polling when hidden

Poll only while the user can see the result, and refresh at once when they come back.

export function visiblePoll(
  fn: () => Promise<void>,
  everyMs: number,
): () => void {
  let timer: ReturnType<typeof setInterval> | undefined;
  const start = () => {
    if (timer !== undefined || document.hidden) return;
    void fn(); // refresh immediately on return
    timer = setInterval(() => void fn(), everyMs);
  };
  const stop = () => {
    clearInterval(timer);
    timer = undefined;
  };
  const onChange = () =>
    document.hidden ? stop() : start();
  const ev = "visibilitychange";
  document.addEventListener(ev, onChange);
  start();
  return () => {
    stop();
    document.removeEventListener(ev, onChange);
  };
}

Send analytics on hide with sendBeacon

Batch events in memory and flush when the page is hidden, the last reliable moment on mobile.

const queue: Record<string, unknown>[] = [];
 
export function track(name: string, props = {}) {
  queue.push({ name, t: Date.now(), ...props });
}
 
function flush() {
  if (queue.length === 0) return;
  const body = JSON.stringify(queue.splice(0));
  // string body = text/plain, so Chrome accepts it
  if (!navigator.sendBeacon("/rum", body)) {
    const init = { method: "POST", body, keepalive: true };
    void fetch("/rum", init);
  }
}
 
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") flush();
});
window.addEventListener("pagehide", flush); // older Safari

Restore after bfcache

Refresh what may be stale and reopen what pagehide closed.

declare function connect(): WebSocket;
declare function refreshSession(): Promise<void>;
 
let socket: WebSocket | null = connect();
 
window.addEventListener("pagehide", () => {
  socket?.close(); // let the page enter the bfcache
  socket = null;
});
 
window.addEventListener("pageshow", (e) => {
  if (!e.persisted) return; // normal load
  socket ??= connect();
  void refreshSession(); // cookies may have expired
});

Guard unsaved changes

Warn before leaving only while there is something to lose.

const onBeforeUnload = (e: BeforeUnloadEvent) => {
  e.preventDefault();
  e.returnValue = true; // legacy browsers
};
 
export function setDirty(dirty: boolean) {
  if (dirty) {
    window.addEventListener("beforeunload", onBeforeUnload);
  } else {
    window.removeEventListener(
      "beforeunload",
      onBeforeUnload,
    );
  }
}

Call setDirty(true) on the first edit and setDirty(false) after saving. Autosave on visibilitychange as well, since iOS never shows the prompt.

References