Area: Saving work before a tab goes away
Related: Browser APIs (map), WebSocket, IndexedDB, BroadcastChannel

What is it?

A web page doesn’t simply “run” and then “close”. Browsers move pages through several states: visible, hidden, frozen, thrown out of memory, kept in a back/forward cache. The Page Lifecycle is the set of states and the events that tell you when they change.

It matters most for one question: how do I make sure the user’s last change gets saved? People close tabs, switch apps on their phone, and lock their laptops in the middle of typing. The answer is much less obvious than “listen for unload”.

The only moment you can count on is the tab being hidden. After that, the browser may freeze or kill the page without telling you.

The states

StateWhat it means
ActiveVisible and focused.
PassiveVisible but not focused (you clicked into another window beside it).
HiddenNot visible: another tab is in front, the window is minimised, the phone switched apps, the screen locked.
FrozenThe browser paused it to save battery or CPU. Timers and tasks stop. Mostly hidden background tabs.
TerminatedBeing unloaded on purpose (user closed it, navigated away).
DiscardedThe browser killed it to free memory. No event fires at all. When the user comes back, it reloads from scratch.

The key insight: from hidden, a page can go to frozen, then discarded, and your code never runs again. On mobile this is common. The user switches to another app, and the OS kills the browser in the background.

The events

EventFires onWhen
visibilitychangedocumenthidden ↔ visible. Check document.visibilityState.
pagehidewindowthe page is being left (closed, navigated away, or put into the back/forward cache)
pageshowwindowthe page is shown, including when restored from the back/forward cache
freeze / resumedocumentChrome only: the page is being frozen or unfrozen
beforeunloadwindowabout to unload; the only event that can show “Leave site?”
unloadwindowold and unreliable. Avoid it.
focus / blurwindowactive ↔ passive
online / offlinewindownetwork status changed (only “is there a network interface”, not “does the internet work”)

The rule: save on visibilitychange to hidden

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") {
    flushPendingSave();
    sendAnalytics();
  }
});

Why this event and not the others:

  • On mobile, when the user switches apps and the OS later kills the browser, pagehide, beforeunload and unload often never fire. visibilitychange to hidden is the last event you reliably get.
  • On desktop, closing a tab fires visibilitychange → pagehide → (unload). Hidden comes first.
  • It also covers “switched tabs and never came back”, which unload events don’t.

Listen for pagehide as well, for the few browser cases where hidden doesn’t fire first. Make your flush function safe to call twice (idempotent), since both may fire.

Why unload is bad

  • It’s unreliable on mobile (see above).
  • A page with an unload handler often can’t enter the back/forward cache, which makes the Back button slower for everyone.
  • Chrome is gradually removing unload handlers.

Don’t use it. Use visibilitychange and pagehide.

beforeunload: only for “you have unsaved changes”

function onBeforeUnload(event: BeforeUnloadEvent) {
  event.preventDefault();
  event.returnValue = ""; // older browsers need this
}
 
// Add it only while there are unsaved changes, and remove it once saved.
if (hasUnsavedChanges) window.addEventListener("beforeunload", onBeforeUnload);
else window.removeEventListener("beforeunload", onBeforeUnload);
  • Browsers show their own generic message. Custom text was removed years ago because scam sites abused it.
  • It only shows if the user has interacted with the page.
  • It doesn’t fire reliably on mobile.
  • Having the listener attached all the time can hurt the back/forward cache. Attach it only while there’s something to lose.

beforeunload can also be used to start a last save, but it’s not reliable on mobile, so it can’t be the only place you save.

Sending data while the page is leaving

A normal fetch started during pagehide may be cancelled when the page goes away. Two tools exist for this.

fetch with keepalive: true

fetch("/api/save", {
  method: "POST",
  body: JSON.stringify(draft),
  headers: { "Content-Type": "application/json" },
  keepalive: true,
});
  • The request keeps going after the page is gone.
  • Any method, any headers (including Authorization), any body type.
  • Limit: 64 KiB total for the bodies of all keepalive requests in flight from the page at once. Over the limit, the fetch fails right away.
  • You can’t use the response if the page is gone. Fire and forget.
navigator.sendBeacon("/api/analytics", JSON.stringify(event));
  • Always a POST. No custom headers. Returns true if the browser queued it.
  • Same kind of size limit.
  • Made for analytics. Simpler, but less flexible than keepalive fetch.

The 64 KiB limit shapes your design

You can’t push a big document out the door in the last millisecond. The reliable designs look like this:

  1. Save continuously while the user works (debounced, every second or two), so there’s never much left to send on exit.
  2. Write locally first. Put pending changes in IndexedDB, which survives a killed tab. On the next load, find unsent changes and send them. The exit-time request then becomes “nice to have”, not the only copy.
  3. Use keepalive only for small final messages: “flush now”, “release my lock”, “sign out”, or a body-less “move to trash”.

Back/forward cache (bfcache)

When you press Back, modern browsers can restore the previous page instantly from memory, JavaScript state and all, instead of reloading it. This is the bfcache.

window.addEventListener("pagehide", (event) => {
  if (event.persisted) {
    // going into the bfcache, may come back
  }
});
 
window.addEventListener("pageshow", (event) => {
  if (event.persisted) {
    // restored from the bfcache: JS state is old.
    // Reconnect sockets, refresh data, re-check auth.
  }
});

What restore means for your app:

  • Timers resume, but the time jump can be hours.
  • WebSocket and other network connections were closed. Reconnect.
  • Data on screen may be stale. Refetch.
  • The user may have signed out in another tab meanwhile.

Things that often prevent a page from entering the bfcache: unload handlers, Cache-Control: no-store on the page, open connections in some browsers, and a page opened with window.opener relationships. Chrome DevTools → Application → Back/forward cache has a “Test” button that lists exactly what’s blocking it.

Frozen and hidden tabs

  • Chrome throttles timers in hidden tabs to once per second, and after a while to once per minute. Don’t rely on setInterval running on time in a background tab.
  • requestAnimationFrame doesn’t run at all while hidden.
  • When a frozen or throttled tab becomes visible again, catch up: refetch, reconnect, re-render. Treat visibilitychange to visible as “the world may have changed”.
  • document.wasDiscarded is true on a page that was reloaded after being discarded. You can use it to restore the user’s scroll position or draft.

A good default setup

function onHidden() {
  flushPendingSaveToIndexedDb(); // local, fast, survives a kill
  sendSmallKeepaliveSignal();    // tiny, best effort
}
 
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") onHidden();
  else onVisibleAgain(); // reconnect, refetch
});
window.addEventListener("pagehide", onHidden);
window.addEventListener("pageshow", (e) => e.persisted && onVisibleAgain());
// beforeunload only while there are unsaved changes

A real-world example

A collaborative notes app handles leaving the page like this:

  • Live editing. The collaboration code listens for pagehide and visibilitychange to flush and tidy up the note’s connection when the tab is hidden or closed.
  • Query cache. The persisted data cache is written to IndexedDB on pagehide, so the next load starts with data.
  • Local saves. The pending save is flushed on beforeunload.
  • Moving a note to trash. It’s a body-less POST with keepalive: true. The delete runs after the dialog closes, and a refresh right after the click used to cancel it. With no body, the 64 KiB limit never applies.
  • Sign-out. Deleting the session cookie uses fetch with keepalive: true, so it completes even if the page navigates away right after.
  • Performance metrics. Web Vitals are sent with navigator.sendBeacon on pagehide and visibilitychange.
  • Large uploads. Code that checkpoints big content knows about the 64 KiB keepalive cap and never relies on keepalive for large bodies.

Gotchas

  • visibilitychange to hidden is the last reliable moment, especially on mobile.
  • unload is unreliable and breaks the bfcache. Don’t use it.
  • beforeunload is for the “Leave site?” warning only, and only while something is unsaved.
  • Keepalive bodies share a 64 KiB budget. Save continuously and keep exit messages tiny.
  • Write unsent work to IndexedDB so it survives a killed tab.
  • pageshow with persisted: true means old JS state. Reconnect and refetch.
  • Background tabs have throttled timers.
  • Make exit handlers safe to run twice.

Practice

  1. Log every lifecycle event with a timestamp to localStorage. Try: switching tabs, minimising, closing a tab, pressing Back then Forward, and switching apps on your phone. Read the logs.
  2. Use DevTools’ bfcache tester on one of your pages and remove whatever blocks it.
  3. Build a text box that saves to IndexedDB on hidden and restores on load. Kill the tab from the browser’s task manager mid-typing and see what survives.
  4. Send a 100 KB body with keepalive: true and watch it fail.

Sources worth reading

  • Chrome Developers: Page Lifecycle API (Philip Walton)
  • web.dev: Back/forward cache
  • MDN: Document: visibilitychange event, Navigator.sendBeacon(), fetch keepalive
  • Chrome Developers: Deprecating the unload event