Skip to content

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.

Without bfcache:  back → full navigation → 1,800ms
With bfcache:     back → restore         →     0ms

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();
  })());
});
// public/sw-config.json — flip this to disable the SW fleet-wide
{ "disabled": false }

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
3. Add 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 unload listeners
  • beforeunload registered conditionally, removed when clean
  • no-store only on checkout/account
  • Sockets and IndexedDB transactions closed on pagehide
  • pageshow refreshes 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

Next: 8.4 Memory & long sessions