How to Restore and Refresh State on pageshow After bfcache
This guide completes the work in Back/Forward Cache (bfcache), within Advanced Caching Strategies & CDN Architecture. A page restored from bfcache resumes exactly as it was when the user left: the same DOM, the same JavaScript state, the same cart count and the same "only 2 left" stock badge. That is the point — it is instant — but some of that state may now be wrong. The user added an item in another tab; the price changed; the session expired; a countdown timer was paused while frozen.
The fix is to treat restoration as a resume event: listen for pageshow with event.persisted === true, and refresh exactly the state that can go stale. Done well, users get an instant page and correct data a moment later. Done badly — or not at all — teams sometimes "fix" stale-data bugs by making pages ineligible for bfcache, throwing away the performance win.
Rapid Diagnosis
- Navigate away and back on key templates after changing state elsewhere (add to cart in another tab, log out, wait past a timer). Note anything stale.
- Check timers and intervals. They pause while frozen and may fire immediately on restore.
- Check connections. WebSockets closed on
pagehidemust reopen onpageshow. - Check analytics. Restored pages should register a view; some tools miss them.
Root Cause Analysis
1. Cross-tab changes. State changed in another tab (cart, preferences, auth) is not reflected in the frozen page.
2. Time-based state. Countdown timers, "time ago" labels, and expiring offers show values from when the page was frozen.
3. Server-side changes. Prices, stock levels and notifications may have changed.
4. Paused activity. Polling and connections stopped on pagehide are not restarted.
Step-by-Step Resolution
1. Centralise restore handling
const onRestore = new Set();
export const whenRestored = (fn) => onRestore.add(fn);
addEventListener('pageshow', (event) => {
if (event.persisted) for (const fn of onRestore) fn();
});
// trade-off: a central registry makes it easy to add work on restore; keep each
// handler cheap and async so restoration stays instant.
Expected outcome: features register their own refresh logic in one consistent way.
2. Refresh cross-tab and server state
whenRestored(async () => {
const summary = await fetch('/api/cart/summary', { cache: 'no-store' }).then((r) => r.json());
cartBadge.textContent = summary.count;
});
// trade-off: a request per restore adds server load; for state that changes in
// other tabs only, BroadcastChannel or storage events can update without a request.
Expected outcome: badges and counts are correct within a few hundred milliseconds of restoration.
3. Recompute time-based values
Store absolute timestamps and recompute labels and countdowns from them, rather than decrementing counters in intervals that pause while frozen.
whenRestored(() => document.querySelectorAll('time[data-relative]').forEach((el) => {
el.textContent = formatRelative(new Date(el.dateTime));
}));
// trade-off: recomputing many labels at once can cost a few milliseconds on
// long pages; limit it to elements in or near the viewport if needed.
4. Re-establish connections and polling
Reopen WebSockets and restart polling that was stopped on pagehide, then fetch any changes missed while frozen.
5. Handle expired sessions
Check the session on restore for authenticated pages and redirect to login if it has ended — the pattern from Cache-Control no-store and bfcache.
Verification
Script the scenario in an end-to-end test: open a product list, navigate to a product, add to cart in a second tab, go back in the first tab, and assert the badge updates. Confirm the restore remains instant (no full reload) by checking pageshow.persisted. Monitor error rates for restore handlers in production.
Worked Example: Restoring Without Losing Instant Back
An online grocery site had disabled bfcache with an unload handler after customers complained that the basket count was wrong after going back. The team removed the handler, added a restore registry, refreshed the basket summary and visible product availability on pageshow, and recomputed delivery slot countdowns from timestamps. Back navigations became instant for 80% of users in Chromium, and complaints about stale baskets stopped because the count now updated within 200ms of returning.
Common Mistakes
- Reloading the page on restore.
location.reload()inpageshowthrows away the benefit entirely. - Refreshing everything. Refetch only what can go stale; re-rendering the whole page negates the instant restore.
- Resetting scroll or form state. Users expect to return to where they were.
- Blocking on refresh. Show the restored page immediately; update asynchronously.
Edge Cases
Optimistic UI. Pending optimistic updates (an item marked "adding…") may have completed or failed while frozen; reconcile with server state on restore.
Single-page apps with client caches. Data libraries like TanStack Query can refetch on window focus; ensure that behaviour also triggers on restore, or call invalidateQueries from the restore handler.
Analytics. Send a page view on restore with the navigation type so restored views are counted.
Feature flags. If flags can change mid-session, decide whether a restored page should pick up new flags; usually it should not change layout under the user.
Framework Integration
Most frameworks do not handle restoration for you, but their data layers make it straightforward. In React, a small hook that subscribes to pageshow and calls your data library's refetch (queryClient.invalidateQueries, SWR's mutate) refreshes visible queries. In Vue, a composable can do the same with Pinia stores or refreshNuxtData() in Nuxt. In both, keep the restore logic declarative: components declare which data is volatile, and a single listener refreshes those queries on restore, rather than every component adding its own pageshow handler. That keeps the restoration path cheap and predictable as the application grows.
FAQ
Does pageshow fire on normal page loads too?
Yes, with persisted false. Check event.persisted to run restore-only logic.
Is visibilitychange enough instead of pageshow?
visibilitychange fires on tab switches too, which is useful for refreshing data when a user returns to a tab, but it does not tell you the page was restored from bfcache. Use pageshow for restore-specific logic and visibilitychange for general freshness.
Will timers fire immediately after restore?
Timers pause while frozen; when the page resumes, any timer whose time has passed may fire promptly. Code that assumes regular intervals should compute from timestamps rather than counting ticks.
How quickly should refreshed data appear?
Within a few hundred milliseconds is typical and acceptable; the page itself is already visible and interactive. Indicate pending refreshes subtly for important values like prices.
Can BroadcastChannel keep tabs in sync instead?
Yes, for cross-tab changes: a tab that adds to the cart can broadcast the new count, and other tabs update on receipt or on restore. Close channels on pagehide if your browser targets treat open channels as blockers.
Should restored pages re-run A/B test assignment?
No. The user already saw a variant; changing it on restore would alter the page under them and corrupt experiment data. Keep the original assignment and only refresh data that can legitimately change, such as prices or counts.
Related
- Measuring bfcache hit rate in RUM — confirming restores happen.
- Stale-while-revalidate data fetching with SWR and TanStack Query — the data-layer equivalent of restore-then-refresh.
- Fixing bfcache eligibility blockers — making restoration possible first.