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
| State | What it means |
|---|---|
| Active | Visible and focused. |
| Passive | Visible but not focused (you clicked into another window beside it). |
| Hidden | Not visible: another tab is in front, the window is minimised, the phone switched apps, the screen locked. |
| Frozen | The browser paused it to save battery or CPU. Timers and tasks stop. Mostly hidden background tabs. |
| Terminated | Being unloaded on purpose (user closed it, navigated away). |
| Discarded | The 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
| Event | Fires on | When |
|---|---|---|
visibilitychange | document | hidden ↔ visible. Check document.visibilityState. |
pagehide | window | the page is being left (closed, navigated away, or put into the back/forward cache) |
pageshow | window | the page is shown, including when restored from the back/forward cache |
freeze / resume | document | Chrome only: the page is being frozen or unfrozen |
beforeunload | window | about to unload; the only event that can show “Leave site?” |
unload | window | old and unreliable. Avoid it. |
focus / blur | window | active ↔ passive |
online / offline | window | network 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,beforeunloadandunloadoften never fire.visibilitychangeto 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
unloadhandler often can’t enter the back/forward cache, which makes the Back button slower for everyone. - Chrome is gradually removing
unloadhandlers.
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
fetchfails right away. - You can’t use the response if the page is gone. Fire and forget.
navigator.sendBeacon
navigator.sendBeacon("/api/analytics", JSON.stringify(event));- Always a POST. No custom headers. Returns
trueif 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:
- Save continuously while the user works (debounced, every second or two), so there’s never much left to send on exit.
- 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.
- 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
setIntervalrunning on time in a background tab. requestAnimationFramedoesn’t run at all while hidden.- When a frozen or throttled tab becomes visible again, catch up: refetch, reconnect, re-render. Treat
visibilitychangeto visible as “the world may have changed”. document.wasDiscardedistrueon 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 changesA real-world example
A collaborative notes app handles leaving the page like this:
- Live editing. The collaboration code listens for
pagehideandvisibilitychangeto 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
POSTwithkeepalive: 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
fetchwithkeepalive: true, so it completes even if the page navigates away right after. - Performance metrics. Web Vitals are sent with
navigator.sendBeacononpagehideandvisibilitychange. - Large uploads. Code that checkpoints big content knows about the 64 KiB keepalive cap and never relies on keepalive for large bodies.
Gotchas
visibilitychangeto hidden is the last reliable moment, especially on mobile.unloadis unreliable and breaks the bfcache. Don’t use it.beforeunloadis 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.
pageshowwithpersisted: truemeans old JS state. Reconnect and refetch.- Background tabs have throttled timers.
- Make exit handlers safe to run twice.
Practice
- 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. - Use DevTools’ bfcache tester on one of your pages and remove whatever blocks it.
- 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.
- Send a 100 KB body with
keepalive: trueand 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