8.3 — Service workers & bfcache¶
Module 8 · Lesson 3 · 🔴 Advanced · ~35 min
What you'll learn¶
- bfcache: the biggest free win in web performance, and the six ways sites break it
- When a service worker helps a commerce site and when it's a liability
- Safe caching strategies per resource type
- Update, versioning, and the "stale app forever" failure mode
Do bfcache first. It's free, it's a bigger win, and it takes an afternoon.
Part 1 — bfcache¶
What it is¶
Back/forward cache stores the entire page in memory when the user navigates away — DOM, JS heap, scroll position, form state. Going back restores it instantly: no network, no re‑render, no hydration.
For commerce, this matters enormously because the dominant browsing pattern is PLP → PDP → back → PDP → back → PDP. Every one of those backs is a full page load if bfcache is broken.
The six blockers¶
| Blocker | Fix |
|---|---|
unload event listener |
Use pagehide instead. Never use unload |
beforeunload listener (in some browsers) |
Add it only when there are unsaved changes, remove it after |
Cache-Control: no-store on the page |
Use no-cache/must-revalidate unless you truly need no-store |
| Open IndexedDB transaction | Close before pagehide |
| Open WebSocket / WebRTC | Close on pagehide, reopen on pageshow |
| In‑flight fetch keeping the page alive | Abort on pagehide |
// ❌ Breaks bfcache in every browser
window.addEventListener('unload', () => flushAnalytics());
// ✅ pagehide is bfcache-safe and more reliable anyway
window.addEventListener('pagehide', (event) => {
flushAnalytics();
if (event.persisted) {
// The page is going INTO bfcache — pause timers, close sockets
pauseCountdowns();
socket?.close();
}
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
// Restored FROM bfcache — the page may be stale
resumeCountdowns();
refreshCartCount(); // cart may have changed in another tab
revalidateStockIfStale();
}
});
The no-store trap¶
This is the most common blocker on commerce sites, and it's usually applied too broadly.
// ❌ Blanket no-store on all pages: bfcache disabled site-wide
async headers() {
return [{ source: '/:path*', headers: [{ key: 'Cache-Control', value: 'no-store' }] }];
}
// ✅ no-store only where legally/technically required
async headers() {
return [
{
source: '/:path(checkout|account)/:rest*',
headers: [{ key: 'Cache-Control', value: 'private, no-store' }],
},
{
// Fresh but bfcache-eligible: revalidates on navigation, restores on back
source: '/cart',
headers: [{ key: 'Cache-Control', value: 'private, no-cache, must-revalidate' }],
},
];
}
no-cache means "revalidate before using", not "don't cache". That's usually what you actually
wanted, and it preserves bfcache.
Conditional beforeunload¶
'use client';
export function CheckoutForm() {
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (!dirty) return; // only registered when needed
const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); };
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [dirty]);
return <form onChange={() => setDirty(true)}>…</form>;
}
Handling stale state on restore¶
A bfcache‑restored PDP might show a price from 20 minutes ago. Refresh what matters:
'use client';
export function BfcacheRefresh() {
const router = useRouter();
useEffect(() => {
const onPageShow = (e: PageTransitionEvent) => {
if (!e.persisted) return;
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const ageMs = Date.now() - (performance.timeOrigin + nav.startTime);
if (ageMs > 5 * 60_000) {
router.refresh(); // re-fetch the RSC payload; keeps client state
} else {
refreshCartCount(); // cheap correctness for the common case
}
};
window.addEventListener('pageshow', onPageShow);
return () => window.removeEventListener('pageshow', onPageShow);
}, [router]);
return null;
}
Testing bfcache¶
Chrome DevTools → Application → Back/forward cache → "Test back/forward cache" — it navigates away and back, then reports eligibility with the specific blocking reason. Run it on every page type.
In the field:
// Measure how often back navigations actually use bfcache
window.addEventListener('pageshow', (e) => {
reportMetric({ name: 'bfcache_restore', value: e.persisted ? 1 : 0 });
});
// NotRestoredReasons API (Chromium) tells you WHY, in production
const nav = performance.getEntriesByType('navigation')[0] as any;
if (nav?.notRestoredReasons) {
reportMetric({
name: 'bfcache_blocked',
value: 1,
attribution: { reasons: JSON.stringify(nav.notRestoredReasons) },
});
}
Aurora's result: four blockers found (an unload listener in a legacy analytics snippet, a
blanket no-store header, an open WebSocket for live stock, and an unconditional beforeunload).
Fixing all four took one day.
| Before | After | |
|---|---|---|
| bfcache restore rate | 4% | 81% |
| p75 back‑navigation time | 1,840 ms | 12 ms |
| Sessions with ≥1 back navigation | 62% | 62% |
Back navigation is 23% of all navigations at Aurora. This is the highest ROI item in Module 8.
Part 2 — Service workers¶
The honest cost/benefit for commerce¶
Benefits: instant repeat visits (shell from cache), offline browsing of viewed products, resilience during flaky connectivity, and a foundation for push notifications.
Costs and risks: - A bad service worker can permanently break your site for users who have it installed. There is no "just deploy a fix" if the SW itself is serving a broken cached shell. - Stale content bugs are subtle and hard to reproduce. - Debugging requires understanding SW lifecycle, which most of your team won't. - It adds a layer between users and your CDN, which already does most of this.
The decision rule: if your CDN caching, ISR, and bfcache are all working well, a service worker adds maybe 10–20% on repeat visits for meaningful risk. Do everything in Modules 2, 3, and Part 1 above first. Consider a service worker only if you need genuine offline capability or you serve markets with very unreliable connectivity.
If you do it: safe strategies¶
// public/sw.js — deliberately minimal and conservative
const VERSION = 'v7';
const STATIC_CACHE = `static-${VERSION}`;
const IMAGE_CACHE = `images-${VERSION}`;
const PRECACHE = ['/offline', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys.filter((k) => !k.endsWith(VERSION)).map((k) => caches.delete(k)),
))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 1. NEVER intercept commerce-critical or cross-origin requests
if (event.request.method !== 'GET') return;
if (url.origin !== self.location.origin) return;
if (/^\/(api|cart|checkout|account)/.test(url.pathname)) return;
// 2. Hashed static assets: cache-first (they're immutable)
if (url.pathname.startsWith('/_next/static/')) {
event.respondWith(cacheFirst(event.request, STATIC_CACHE));
return;
}
// 3. Images: stale-while-revalidate, with a size cap
if (/\.(avif|webp|jpg|jpeg|png|svg)$/.test(url.pathname)) {
event.respondWith(staleWhileRevalidate(event.request, IMAGE_CACHE));
return;
}
// 4. HTML: network-first with an offline fallback. NEVER cache-first —
// a cache-first HTML strategy is how you serve last week's prices.
if (event.request.mode === 'navigate') {
event.respondWith(networkFirst(event.request));
return;
}
});
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) (await caches.open(cacheName)).put(request, response.clone());
return response;
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const network = fetch(request).then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
}).catch(() => cached);
return cached || network;
}
async function networkFirst(request) {
try {
// Short timeout: on a flaky connection, fall back fast
const response = await Promise.race([
fetch(request),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 3000)),
]);
return response;
} catch {
return (await caches.match('/offline')) ?? Response.error();
}
}
Strategy per resource type¶
| Resource | Strategy | Rationale |
|---|---|---|
| Hashed JS/CSS | Cache‑first | Immutable by construction |
| Fonts | Cache‑first | Immutable |
| Product images | Stale‑while‑revalidate + cap | Large, mostly stable |
| HTML | Network‑first with offline fallback | Prices and stock must be current |
| API responses | Don't intercept | Correctness; your CDN handles it |
| Cart/checkout/account | Don't intercept | Never |
The kill switch — build it before you ship¶
// public/sw.js — check a remote flag on activation
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
try {
const res = await fetch('/sw-config.json', { cache: 'no-store' });
const config = await res.json();
if (config.disabled) {
await Promise.all((await caches.keys()).map((k) => caches.delete(k)));
await self.registration.unregister();
const clients = await self.clients.matchAll();
clients.forEach((c) => c.navigate(c.url)); // reload without the SW
return;
}
} catch { /* config unavailable — carry on with the existing SW */ }
await self.clients.claim();
})());
});
Ship the kill switch in version 1. If you don't, a bad service worker is a genuine incident with no fast remediation — you'll be waiting for users' browsers to check for an update.
Update handling¶
'use client';
export function ServiceWorkerUpdater() {
useEffect(() => {
if (!('serviceWorker' in navigator) || process.env.NODE_ENV !== 'production') return;
navigator.serviceWorker.register('/sw.js').then((registration) => {
// Check for updates periodically for long-lived sessions
setInterval(() => registration.update(), 60 * 60_000);
registration.addEventListener('updatefound', () => {
const installing = registration.installing;
installing?.addEventListener('statechange', () => {
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
// A new version is ready. Don't force-reload mid-checkout —
// prompt, or apply on the next navigation.
showUpdateToast(() => {
installing.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
});
}
});
});
});
}, []);
return null;
}
Never force a reload without asking. A user mid‑checkout losing their form to an automatic service‑worker reload is a lost order and a support ticket.
Offline UX for commerce¶
Genuine offline shopping is rarely worth building. What is worth it:
// A minimal offline page that keeps the user oriented
export default function OfflinePage() {
return (
<main className="mx-auto max-w-md p-8 text-center">
<h1 className="text-xl font-medium">You're offline</h1>
<p className="mt-2 text-neutral-600">
Your bag is saved. We'll pick up where you left off when you reconnect.
</p>
<button onClick={() => location.reload()} className="mt-6 …">Try again</button>
</main>
);
}
Plus: - Persist the cart locally and sync on reconnect (do this regardless of service workers) - Queue analytics events and flush when back online - Show a connection banner rather than letting actions fail silently
// Background sync for cart mutations (Chromium)
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
await (reg as any).sync.register('sync-cart');
}
Common mistakes¶
| Mistake | Cost |
|---|---|
unload listeners |
bfcache disabled for the whole site |
Blanket no-store |
Same |
| Investing in a service worker before fixing bfcache | Working hard for the smaller win |
| Cache‑first HTML | Stale prices, stale stock, real revenue damage |
Intercepting /api, /cart, /checkout |
Correctness and security risk |
| No kill switch | A bad SW becomes an incident with no fast fix |
| Forcing reload on SW update | Lost checkout sessions |
| No cache size limits | Storage quota exhaustion on mobile |
| Not testing the update path | Users stuck on an old version indefinitely |
Lab 8.3 — bfcache and (maybe) a service worker¶
Part 1 — bfcache (do this): 1. Test every page type with DevTools → Application → Back/forward cache. 2. Fix every blocker. Search for them:
rg -n "addEventListener\(['\"]unload" app components lib
rg -n "beforeunload" app components lib
rg -n "no-store" next.config.ts app middleware.ts
pageshow/pagehide handlers for state refresh and socket management.
4. Ship the field measurement (e.persisted + notRestoredReasons).
5. Measure back‑navigation timing before and after.
Part 2 — service worker (only if justified): 1. Write down the specific benefit you expect and what it's worth, given your CDN caching is already working. 2. If you proceed: build the kill switch first, then the SW. 3. Never cache‑first HTML. Never intercept commerce paths. 4. Roll out to 1% → 10% → 50%, watching error rates and stale‑content reports. 5. Test the update path and the kill switch in staging before production.
Checklist¶
- Zero
unloadlisteners -
beforeunloadregistered conditionally, removed when clean -
no-storeonly on checkout/account - Sockets and IndexedDB transactions closed on
pagehide -
pageshowrefreshes stale state on restore - bfcache restore rate measured in the field
- Service worker: only if justified after CDN + bfcache work
- Kill switch shipped in v1
- Network‑first for HTML; commerce paths never intercepted
- Cache size caps; update path tested