pwa-offline-cache-contracts
PWA offline cache contracts
A service worker turns your app into cached bytes that outlive any single deploy, so every cache decision is a freshness contract: what is precached, when a new worker takes over, when old caches are deleted, and what must never be cached at all. The bugs are not in the offline happy path — they are stale builds that never update, navigations that fail on purged chunks, and per-user responses served to the wrong session.
Checklist (lead with the trap)
- A long-cached index.html plus content-hashed chunks is the classic post-deploy break. The HTML is cached with a long
max-agewhile a deploy emits new hashed chunks (app-B2.js) and purges the old (app-A1.js). Returning users load the stale HTML, it still asks for the purged chunk, and the dynamic import rejects withChunkLoadError(webpack) orFailed to fetch dynamically imported module(Vite native ESM). Serve HTML withCache-Control: no-cache(revalidate every load) or route it Network First in the SW, keep hashed assets immutable/Cache First, and add avite:preloadError/ lazy-import handler that reloads once with a retry cap — never an unbounded reload loop. - The waiting worker is why "I deployed but users still see the old app." A new worker installs then sits in
waitinguntil every client controlled by the old worker closes; a refresh does not release it because clients overlap. So a deploy silently does nothing for active tabs. Detect the update (updatefound->statechangetoinstalled, and also checkregistration.waitingon load in case the prompt was missed) and give the user a path to activate.registration.update()only refetches the script; a byte-different worker still waits. - Delete old caches in activate, keyed by an explicit version — not in install. Open a versioned cache name per release (
app-static-v3); onactivate, walkcaches.keys()and delete any not in your current allowlist. Do cleanup inactivate, notinstall, because the old worker is still serving pages while the new one installs. Installing a new worker does not evict old caches for you; without cleanup they accumulate against the storage quota. - cache.addAll is all-or-nothing: one bad URL aborts the whole install.
cache.addAllis atomic (Workbox precaching fetches entries individually rather than viaaddAll; a failed entry likewise rejects install so the new worker never activates, though entries already written are not rolled back) — if any single request fails (a 404, a redirect, an opaque cross-origin response), the promise rejects, and wrapped inevent.waitUntilthe install fails, so nothing is cached and the worker never activates. These rejections are hard to see from inside the worker. Precache only same-origin URLs you expect to return 200; cache optional or third-party assets separately withcache.putand per-item error handling. - Custom globPatterns replace the default match set — enumerate every asset type. In
workbox-build/ vite-plugin-pwa, settingglobPatternsoverrides the defaults instead of extending them. The defaults cover only a subset (JS/CSS/HTML); once you set your own,woff2fonts,png/svgicons, and JSON drop out of the precache and the app breaks only offline. List every extension the app needs and mindmaximumFileSizeToCacheInBytes(large files are silently skipped). A built-manifest assertion (HTML + JS + CSS + icons + fonts all present) catches the regression. - Decide skipWaiting / clients.claim vs prompt-to-reload deliberately.
self.skipWaiting()activates the new worker immediately, so it can control pages loaded by the old version — early fetches were served old, later ones new, mixing mismatched HTML and chunks.clients.claim()lets an active worker control already-open tabs. Both are fine for pure precache-and-serve; for an app where mixed versions break, prefer a prompt: skip waiting only when the user accepts, then reload oncontrollerchange. Do not pasteskipWaiting+clients.claimas boilerplate without deciding. - Give navigations a fallback and a real offline page. For an SPA, register a
NavigationRouteserving the precached shell (createHandlerBoundToURL('/index.html')) so client-side routes resolve offline, withallowlist/denylistso it does not swallow API or file URLs (it matches all navigations by default). Provide a dedicated offline fallback for navigations that miss the cache — otherwise a first offline visit shows the browser error page, not your app. - Never precache or runtime-cache authenticated HTML/API responses. Cache Storage is per-origin, not per-user, and the Cache API ignores HTTP cache headers — whatever you put stays until you delete it. Caching an authenticated page or per-user API JSON lets the next session (logout/login, shared device) read another user's data, or stale-after-logout data. Route authenticated/personalized endpoints Network Only (never written to cache); reserve Cache First for hash-versioned static assets and Network First for shell HTML, and gate cacheable responses to status 200 so opaque (status 0) responses are not stored blindly.
Quick probes
Treat hits as leads; open the SW source and the build config before filing. Route mechanical checks to your build tool's Workbox output (glob count, precache size, size-limit warnings) and DevTools Application panel — Service Workers shows the waiting worker and the Update-on-reload switch, Cache Storage shows what was actually precached. (Lighthouse's PWA category was removed in v12; do not point CI at it.)