document.visibilityState is "visible" or "hidden", and visibilitychange fires whenever it flips: another tab, a minimized window, a phone going to sleep. Pause loops, polls and camera tracks there, and save there too, because hidden is the last callback a mobile browser guarantees. Chromium 4,389 adds two states: a hidden tab may be frozen to reclaim CPU (freeze, then resume), and a frozen tab may be discarded, after which the reload sets document.wasDiscarded.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/autosave', draft.value); // never use unload for this
clearInterval(poll);
} else poll = setInterval(refresh, 30_000);
});
addEventListener('freeze', () => socket.close()); // Chromium: tab put to sleep
addEventListener('resume', () => connect());
if (document.wasDiscarded) restoreFrom(sessionStorage);visibilitychange is not an unload hook — it also fires when the user merely glances at another tab; for "really going away" use pagehide, which pairs with the back/forward cache (Core Web Vitals). Page Visibility is Baseline Widely available; freeze, resume and wasDiscarded are Chromium-only (Chrome 1 and Edge since 2018), so treat them as an optimization. Headless Chrome keeps every page visible, so the hidden branch needs a real tab switch.