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 one at a time rather than viaaddAll; a response it will not cache throwsbad-precaching-response, which fails install so the new worker never activates, and the install loop has no rollback for entries already written) — if any single request fails (a non-2xx status such as a 404, or a cross-originno-corsrequest, whose reported status is always 0), 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. - The default precache glob matches only JS/CSS/HTML — enumerate every other asset type. vite-plugin-pwa (through
workbox-build) precaches onlycss,js, andhtmlby default, sowoff2fonts,png/svgicons, JSON, and other assets are never in the manifest until you list them inglobPatterns, and the app breaks only offline. A customglobPatternsreplaces the default rather than extending it, so it must re-listjs/css/htmlas well. Files overmaximumFileSizeToCacheInBytes(2 MiB default) are left out of the manifest:workbox-buildemits a "won't be precached" warning, and vite-plugin-pwa from 0.20.2 turns that warning into a build error unlessshowMaximumFileSizeToCacheInBytesWarningis set. Decide that limit deliberately rather than raising it blindly. 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 — this skill owns that ruling. OWASP's rule is "Do not cache responses that contain sensitive data." (The same OWASP passage adds that
Cache-Control: no-storekeeps the Cache API from retaining them; MDN's Cache interface doc governs here, and it says the Cache API does not honor HTTP caching headers.) Cache Storage is per-origin, not per-user, and the Cache API ignores HTTP cache headers — whatever the worker writes stays until you delete it, soCache-Control: no-storeon the response does not stop a service-worker route that callscache.putitself. 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.)