web-pwa-service-workers · diff
git:20260328.03e71dd to git:20260906.9d6ccd9
162 added, 180 removed. Audit A to A.
---
name: web-pwa-service-workers
description: Service Worker lifecycle, caching strategies, offline patterns, update handling, precaching, runtime caching
---
# Service Worker Patterns
- > **Quick Guide:** Use Service Workers for offline-first applications with sophisticated caching. Implement cache-first for static assets, network-first for HTML, and stale-while-revalidate for API data. Always handle the install/activate/fetch lifecycle properly, version your caches, and provide user control over updates. Clone responses before caching (body can only be consumed once).
+ > **Quick Guide:** A service worker is a programmable network proxy with its own lifecycle:
+ > install precaches, activate cleans up, fetch intercepts. Match the caching strategy to the
+ > content — cache-first for hashed assets, network-first for HTML, stale-while-revalidate for
+ > non-critical API reads. Two details cause most bugs: a response body can be read once, so
+ > `cache.put(request, response.clone())`, and a new worker waits until every tab using the old one
+ > closes, so updates need explicit detection and a user-triggered `skipWaiting`.
+ **Detailed Resources:**
+
+ - [examples/core.md](examples/core.md) — registration, the full lifecycle template, the four strategy implementations, message types
+ - [examples/caching.md](examples/caching.md) — background refresh, expiry timestamps, per-endpoint routing, navigation preload, quota cleanup
+ - [examples/updates.md](examples/updates.md) — version messaging, aggressive/deferred/idle updates, progressive rollout, cache migration
+ - [reference.md](reference.md) — strategy-by-content-type table, lifecycle and Cache API lookup, update-strategy selection, review checklist
+
---
- <critical_requirements>
+ ## Which path applies
- ## CRITICAL: Before Using This Skill
+ - **Precached app shell** — the HTML is one cached document and routing happens client-side. Serve
+ it cache-first, and skip navigation preload; there is nothing to preload.
+ - **Server-rendered or authenticated pages** — each navigation is a fresh document. Serve
+ network-first with a timeout and an offline fallback, and enable navigation preload so the
+ request starts before the worker boots. Follow
+ [examples/caching.md](examples/caching.md) Pattern 8.
- > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
+ ---
- **(You MUST call `event.waitUntil()` in install and activate handlers to signal completion)**
+ <critical_requirements>
- **(You MUST version your caches and clean up old versions during activation)**
+ ## Before writing service worker code
- **(You MUST clone responses before caching - `cache.put(request, response.clone())` - response body can only be consumed once)**
+ **Wrap every async lifecycle task in `event.waitUntil()`.** It keeps the worker alive until the
+ promise settles; without it the browser is free to terminate mid-precache.
- **(You MUST implement proper update detection and give users control over when updates apply)**
+ **Put a version in every cache name and delete the non-current ones during activate.** That is
+ what makes an upgrade clean and keeps storage from growing without bound.
- **(You MUST handle all fetch failures with appropriate offline fallbacks)**
+ **Clone before caching — `cache.put(request, response.clone())`.** A response body can be consumed
+ once, so caching the original leaves the client with an empty response.
- </critical_requirements>
+ **Let the client decide when a waiting worker takes over.** Detect the waiting worker, tell the
+ user, and call `skipWaiting()` in response to their message, so behaviour never changes underneath
+ an open session.
- ---
+ **Give every fetch path a fallback.** A precached `offline.html` for navigations and a constructed
+ `Response` as the last resort turn a network failure into a page you wrote.
- **Auto-detection:** Service Worker, serviceWorker, sw.js, sw.ts, navigator.serviceWorker, caches, Cache API, CacheStorage, skipWaiting, clients.claim, precache, offline-first, PWA
+ </critical_requirements>
- **When to use:**
+ ---
- - Building Progressive Web Apps (PWAs) with offline support
- - Implementing sophisticated caching strategies beyond browser defaults
- - Providing offline fallback pages or cached content
- - Controlling how network requests are handled and cached
+ **Auto-detection:** navigator.serviceWorker, serviceWorker.register, ServiceWorkerGlobalScope,
+ sw.js, sw.ts, self.skipWaiting, clients.claim, event.waitUntil, event.respondWith,
+ event.preloadResponse, caches.open, caches.match, cache.addAll, CacheStorage, navigationPreload,
+ updateViaCache, controllerchange, updatefound, precache
- **When NOT to use:**
+ **Applies to:**
- - Simple websites without offline requirements
- - When browser HTTP caching is sufficient
- - For real-time data that must always be fresh (use network-only)
+ - Intercepting and answering network requests from a worker thread
+ - Choosing and implementing a caching strategy per content type
+ - Precaching an app shell and cleaning up superseded caches
+ - Detecting a waiting worker and applying the update on the user's terms
+ - Serving an offline fallback when both cache and network fail
- **Detailed Resources:**
+ **Handled elsewhere:**
- - [examples/core.md](examples/core.md) - Registration, lifecycle template, caching strategy implementations, types
- - [examples/caching.md](examples/caching.md) - Advanced caching (expiration, selective API, storage cleanup, navigation preload)
- - [examples/updates.md](examples/updates.md) - Version tracking, update strategies (aggressive, deferred, idle, rollout, migration)
- - [reference.md](reference.md) - Decision frameworks, anti-patterns, lifecycle reference, checklists
+ - Structured local data that the application owns and writes — a response cache stores what the
+ server said, which is a different lifetime from records a user edits offline
+ - Queueing mutations made while disconnected and reconciling them later
+ - Push notification content and permission flows — the worker's `push` event is a delivery hook,
+ and what to show is a product decision
---
<philosophy>
## Philosophy
- Service Workers are **programmable network proxies** that run in a separate thread, intercepting requests between your application and the network. They enable offline functionality, sophisticated caching, and background operations.
-
- **The Service Worker lifecycle is designed for safety:**
+ A service worker is a proxy, not a plugin: once installed it sees every request in its scope, and
+ anything it fails to answer, it breaks.
- 1. **Install Phase:** Download and cache critical assets. The worker is "waiting" until installation completes.
- 2. **Waiting Phase:** New workers wait for all tabs using the old worker to close, preventing version conflicts.
- 3. **Activate Phase:** Old caches are cleaned up, and the worker takes control.
- 4. **Fetch Phase:** The active worker intercepts all network requests within its scope.
+ The lifecycle exists to stop two versions running at once. A new worker installs immediately but
+ **waits** until every client controlled by the old one has gone, so one page never runs half the old
+ assets and half the new ones.
```
Registration → Download → Install → Waiting → Activate → Fetch
↓ ↓
(skipWaiting) (claim)
```
- **Core Principles:**
-
- 1. **Safety First:** The lifecycle prevents running multiple versions simultaneously, which could corrupt state.
- 2. **User Control:** Users should decide when updates apply, not be surprised by sudden behavior changes mid-session.
- 3. **Graceful Degradation:** Always provide fallbacks when network and cache both fail.
- 4. **Cache Versioning:** Version your caches to enable clean upgrades and prevent unbounded growth.
+ `skipWaiting()` and `clients.claim()` are the two escapes from that guarantee, and both are opt-in
+ for a reason. Reach for them when the user has asked for the update, or when a fix is urgent enough
+ to be worth a mid-session change.
</philosophy>
---
<patterns>
- ## Core Patterns
+ ## Core patterns
- ### Pattern 1: Service Worker Registration
+ ### Pattern 1: Registration with update detection
- Register from your main application with feature detection, update checking, and user-controlled updates.
+ Register with feature detection, check for updates periodically, and track the waiting worker so
+ the UI can offer the update.
```typescript
- const SW_PATH = "/sw.js";
- const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
-
- const registration = await navigator.serviceWorker.register(SW_PATH, {
+ const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
- updateViaCache: "none", // Always check server for updates
+ updateViaCache: "none", // ask the server for the worker script every time
});
- // Periodic update checks
setInterval(() => registration.update(), UPDATE_CHECK_INTERVAL_MS);
- // Track waiting worker for user-controlled updates
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
installing?.addEventListener("statechange", () => {
- if (
- installing.state === "installed" &&
- navigator.serviceWorker.controller
- ) {
- // New version waiting - notify user
- }
+ const isWaiting =
+ installing.state === "installed" && navigator.serviceWorker.controller;
+ if (isWaiting) notifyUpdateAvailable();
});
});
```
- See [examples/core.md](examples/core.md) Pattern 1 for complete registration with update tracking and reload handling.
-
- ---
+ Full code: [examples/core.md](examples/core.md)
- ### Pattern 2: Lifecycle Handlers (Install / Activate / Message)
+ ### Pattern 2: Lifecycle handlers
- The three essential lifecycle event handlers: precache in install, cleanup in activate, user-controlled skipWaiting via message.
+ Precache in install, delete superseded caches in activate, and take `skipWaiting` as a message
+ rather than calling it unconditionally.
```typescript
- // Install - precache critical assets
self.addEventListener("install", (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHES.static).then((cache) => cache.addAll(PRECACHE_URLS)),
);
- // Do NOT call skipWaiting here - let user control updates
});
- // Activate - cleanup old caches, claim clients
self.addEventListener("activate", (event: ExtendableEvent) => {
event.waitUntil(
- caches
- .keys()
- .then((names) =>
- Promise.all(
- names
- .filter((n) => !currentCaches.includes(n))
- .map((n) => caches.delete(n)),
- ),
- )
- .then(() => self.clients.claim()),
+ (async () => {
+ const names = await caches.keys();
+ const current: string[] = Object.values(CACHES);
+ await Promise.all(
+ names.filter((n) => !current.includes(n)).map((n) => caches.delete(n)),
+ );
+ await self.clients.claim();
+ })(),
);
});
- // Message - user-controlled skipWaiting
self.addEventListener("message", (event: ExtendableMessageEvent) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
```
- See [examples/core.md](examples/core.md) Pattern 2 for complete template with constants and type safety.
-
- ---
-
- ### Pattern 3: Caching Strategies
+ Full code: [examples/core.md](examples/core.md)
- Four strategies to match content types:
+ ### Pattern 3: Caching strategies
- | Strategy | When to Use | Behavior |
- | ----------------------------- | ---------------------------------- | ------------------------------------------- |
- | **Cache-first** | Static assets, fonts, hashed files | Return cached immediately, network fallback |
- | **Network-first** | HTML pages, user-specific API data | Try network with timeout, cache fallback |
- | **Stale-while-revalidate** | Avatars, non-critical API, feeds | Return cached, refresh in background |
- | **Cache-only / Network-only** | Precached shells / real-time data | Single source, no fallback |
+ Four strategies cover almost everything. Which content gets which is in
+ [reference.md](reference.md).
- Key implementation details:
+ | Strategy | Behaviour |
+ | ----------------------------- | ----------------------------------------------- |
+ | **Cache-first** | Serve the cached copy, fall back to the network |
+ | **Network-first** | Race the network against a timeout, then cache |
+ | **Stale-while-revalidate** | Serve the cached copy and refresh in background |
+ | **Cache-only / network-only** | One source, no fallback |
- - Always check `response.ok` before caching (avoid caching 404/500)
- - Always `response.clone()` before `cache.put()` (body consumed once)
- - Add timeout to network-first to avoid hanging on slow connections
- - Limit cache size to prevent unbounded storage growth
+ Each implementation checks `response.ok` before caching, clones before `cache.put`, and caps the
+ number of entries.
```typescript
- // The clone pattern - response body can only be consumed once
const networkResponse = await fetch(request);
if (networkResponse.ok) {
- cache.put(request, networkResponse.clone()); // Clone for cache
+ cache.put(request, networkResponse.clone()); // the clone is cached
}
- return networkResponse; // Original for client
+ return networkResponse; // the original goes to the client
```
- See [examples/core.md](examples/core.md) Pattern 2 for all strategy implementations in the complete template, and [examples/caching.md](examples/caching.md) for advanced patterns (expiration, selective API caching, storage cleanup).
-
- ---
+ Full code: [examples/core.md](examples/core.md); expiry and per-endpoint variants in
+ [examples/caching.md](examples/caching.md)
- ### Pattern 4: Fetch Event Routing
+ ### Pattern 4: Fetch routing
- Route requests to appropriate caching strategies based on request type and URL.
+ Route by request shape rather than by URL alone: `request.mode`, `request.destination` and the
+ pathname each answer a different question. Returning without calling `respondWith` lets the browser
+ handle the request normally.
```typescript
self.addEventListener("fetch", (event: FetchEvent) => {
const { request } = event;
const url = new URL(request.url);
- if (request.method !== "GET") return; // Skip non-GET
- if (url.origin !== location.origin) return; // Skip cross-origin
+ if (request.method !== "GET") return;
+ if (url.origin !== location.origin) return;
if (request.mode === "navigate") {
event.respondWith(networkFirst(request, CACHES.pages));
} else if (request.destination === "image") {
- event.respondWith(
- cacheFirstWithLimit(request, CACHES.images, MAX_CACHE_ITEMS.images),
- );
+ event.respondWith(cacheFirstWithLimit(request, CACHES.images, MAX_IMAGES));
} else if (url.pathname.startsWith("/api/")) {
event.respondWith(staleWhileRevalidate(request, CACHES.api));
} else {
event.respondWith(cacheFirst(request, CACHES.static));
}
});
```
- ---
+ Full code: [examples/core.md](examples/core.md)
- ### Pattern 5: Offline Fallback
+ ### Pattern 5: Offline fallback
- Always precache an offline.html page and return it when both cache and network fail for navigation requests.
+ Precache an offline page in install and serve it when a navigation has no cache entry and no
+ network. Construct a `Response` as the final resort, so there is no path that ends in the browser's
+ own error page.
```typescript
- // In install handler: precache offline.html
- // In fetch error handling:
if (request.mode === "navigate") {
const offlinePage = await caches.match("/offline.html");
if (offlinePage) return offlinePage;
}
- // Last resort: inline response
- return new Response(
- "<html><body><h1>Offline</h1><p>Check your connection.</p></body></html>",
- { status: 503, headers: { "Content-Type": "text/html" } },
- );
+ return new Response("<h1>Offline</h1><p>Check your connection.</p>", {
+ status: 503,
+ headers: { "Content-Type": "text/html" },
+ });
```
- ---
+ Full code: [examples/core.md](examples/core.md)
- ### Pattern 6: Navigation Preload
+ ### Pattern 6: Navigation preload
- Fetch navigation requests in parallel with service worker bootup, reducing latency for network-first HTML. Enable in activate, consume via `event.preloadResponse` in fetch.
+ Worker bootup costs roughly 50ms on desktop and 250ms or more on a slow phone, and a network-first
+ navigation pays it before the request even starts. Navigation preload starts the request in
+ parallel with bootup.
```typescript
- // Activate: enable navigation preload
+ // activate
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
- // Fetch: use preloaded response (avoids double fetch)
+ // fetch
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
cache.put(event.request, preloadResponse.clone());
return preloadResponse;
}
```
- **Warning:** If you enable navigation preload, you MUST use `event.preloadResponse`. Using `fetch(event.request)` instead results in two network requests for the same resource.
-
- **When to use:** Network-first HTML pages with dynamic/authenticated content. Not needed for precached app shells.
-
- See [examples/caching.md](examples/caching.md) Pattern 8 for complete implementation.
+ Once enabled, consume `event.preloadResponse`; calling `fetch(event.request)` instead makes two
+ network requests for the same resource.
- ---
+ Full code: [examples/caching.md](examples/caching.md)
- ### Pattern 7: Update Handling
+ ### Pattern 7: User-controlled updates
- Users should control when updates apply. Detect waiting workers, notify users, and let them trigger `skipWaiting`.
+ Show the waiting worker to the user, post `SKIP_WAITING` when they accept, and reload once on
+ `controllerchange` so the page and the worker agree.
```typescript
- // Client: detect and apply updates
- if (registration.waiting) {
- showUpdateBanner();
- }
+ if (registration.waiting) showUpdateBanner();
function applyUpdate() {
registration.waiting?.postMessage({ type: "SKIP_WAITING" });
}
- // Reload when new worker takes control
+ let refreshing = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
+ if (refreshing) return;
+ refreshing = true;
window.location.reload();
});
```
- See [examples/updates.md](examples/updates.md) for version tracking, aggressive updates, deferred updates, idle-time updates, progressive rollout, and data migration patterns.
+ Full code: [examples/updates.md](examples/updates.md) — version messaging, deferred and idle
+ application, progressive rollout, cache migration
</patterns>
---
<red_flags>
- ## RED FLAGS
-
- **High Priority Issues:**
-
- - No `event.waitUntil()` in install/activate - browser may terminate SW before async operations complete
- - Calling `skipWaiting()` unconditionally in install - users experience unexpected behavior changes mid-session
- - No cache versioning - old cached content persists forever, storage grows unbounded
- - Not cleaning up old caches in activate - storage quota eventually exceeded
- - Missing offline fallback - users see browser error page instead of helpful message
- - Not checking `response.ok` before caching - error responses (404, 500) get cached and served
+ ## Red flags
- **Medium Priority Issues:**
+ **Breaks at runtime:**
- - No timeout on network requests in network-first strategy - fetch hangs indefinitely on slow connections
- - Not cloning response before caching - response body consumed, client gets empty response
- - No cache size limits - unbounded growth leads to quota issues
- - Attempting to cache POST requests - only GET requests are cacheable
+ - Async work in install or activate without `event.waitUntil()` — the browser may terminate the
+ worker mid-task — wrap the promise
+ - `cache.put(request, response)` without `.clone()` — the body is consumed and the client receives
+ nothing — cache the clone and return the original
+ - Caching without checking `response.ok` — a 404 or 500 is stored and served back for the life of
+ the cache — gate the `put`
+ - Unversioned cache names — activate has no way to tell current caches from superseded ones —
+ interpolate a version constant
+ - No `offline.html` for navigations — the user gets the browser's error page — precache one in
+ install and return it from the fetch error path
+ - `fetch(event.request)` in a worker with navigation preload enabled — every navigation makes two
+ network requests — consume `event.preloadResponse`
+ - Trying to cache a POST — the Cache API keys on GET requests — return early on
+ `request.method !== "GET"`
- **Gotchas & Edge Cases:**
+ **Surprising behaviour:**
- - Service workers only work over HTTPS (exception: localhost for development)
- - Scope determined by SW file location - `/sw.js` controls `/`, but `/scripts/sw.js` only controls `/scripts/`
- - Browser may terminate idle service workers - do not rely on in-memory state
- - `clients.claim()` does not trigger reload - clients keep running old page with new SW
- - Chrome DevTools "Update on reload" bypasses waiting - useful for dev, not representative of production
- - Web app manifest changes do not trigger SW update - only byte changes to SW file itself
- - IndexedDB transactions cannot span `await` - complete DB work in single transaction
- - Opaque responses (cross-origin without CORS) count against storage quota at inflated cost
- - Service worker bootup varies: ~50ms desktop, ~250ms mobile, 500ms+ slow devices - navigation preload mitigates this
+ - Scope follows the script's location: `/sw.js` controls `/`, but `/scripts/sw.js` controls only
+ `/scripts/`
+ - Service workers need HTTPS, with localhost exempted for development
+ - An idle worker is terminated and restarted, so module-level state does not survive between events
+ - `clients.claim()` takes control without reloading, so an open page keeps the old HTML alongside
+ the new worker
+ - Only a byte change to the worker script triggers an update — editing the web app manifest or a
+ precached asset does not
+ - DevTools "Update on reload" bypasses the waiting phase, so update handling looks correct in
+ development and fails in production
+ - Opaque cross-origin responses count against the storage quota at a padded size far above their
+ real one
+ - Network-first without a timeout hangs indefinitely on a connection that is technically up
</red_flags>
-
- ---
-
- <critical_reminders>
-
- ## CRITICAL REMINDERS
-
- > **All code must follow project conventions in CLAUDE.md**
-
- **(You MUST call `event.waitUntil()` in install and activate handlers to signal completion)**
-
- **(You MUST version your caches and clean up old versions during activation)**
-
- **(You MUST clone responses before caching - `cache.put(request, response.clone())` - response body can only be consumed once)**
-
- **(You MUST implement proper update detection and give users control over when updates apply)**
-
- **(You MUST handle all fetch failures with appropriate offline fallbacks)**
-
- **Failure to follow these rules will result in broken updates, unbounded cache growth, and poor offline experience.**
-
- </critical_reminders>