Service Workers and PWAs in 2026
Build offline-first apps with service workers, Cache API, and Background Sync.
Lifecycle, Precisely
A service worker moves through installing, waiting, and activating states. Install runs once per new worker version — cache your precache manifest there. Activate runs when the worker takes over — delete old caches there. Fetch intercepts every request in scope thereafter.
- self.addEventListener('install', e => { e.waitUntil(caches.open('v3').then(c => c.addAll(['/', '/app.js']))); });
- self.addEventListener('activate', e => { e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== 'v3').map(k => caches.delete(k))))); });
The subtlety is the waiting state: a new worker version installs but does not take over while any tab still uses the old one. self.skipWaiting() plus clients.claim() forces immediate takeover — convenient, but it creates version skew: a page built for API v2 suddenly served by a v3 worker. The safer default is to leave waiting alone and show a "new version available — reload" toast driven by the updatefound event on the registration.
How Updates Actually Happen
The browser re-fetches the worker script (byte-compare) on navigation and at most every 24 hours. Consequences: your SW file must not be served with long-lived cache headers (Cache-Control: max-age=0 is the convention), and a worker that never changes bytes never updates even if your app does. Build systems typically inject a precache manifest hash into the worker file so any asset change produces a byte-diff.
Caching Strategies by Content Type
- Cache first — hashed, versioned static assets (app.3f9c.js): immutable by construction, so network checks are waste
- Network first, cache fallback — HTML documents and API GETs: fresh when online, functional when offline
- Stale-while-revalidate — avatars, fonts, non-critical JSON: respond instantly from cache, refresh it in the background
Routing skeleton:
- self.addEventListener('fetch', e => {
- if (e.request.mode === 'navigate') e.respondWith(networkFirst(e.request));
- else e.respondWith(staleWhileRevalidate(e.request));
- });
Never cache non-GET requests, and be deliberate about opaque responses (cross-origin no-CORS fetches): you cannot read their status, so you may cache an error forever, and they are padded heavily against your storage quota — hundreds of kilobytes charged for a tiny asset.
Navigation Preload
A fetch handler adds SW boot time to every navigation. Navigation preload starts the network request in parallel with worker startup: enable self.registration.navigationPreload.enable() in activate, then use e.preloadResponse in the fetch handler before falling back to fetch(e.request). On cold starts this recovers 50–200ms of first-byte time.
Background Sync and Push
Queue failed writes in IndexedDB, register a sync tag, and replay when connectivity returns:
- self.addEventListener('sync', e => { if (e.tag === 'flush-outbox') e.waitUntil(flushOutbox()); });
One-shot Background Sync is Chromium-only; treat it as progressive enhancement over an in-page retry queue, not a foundation. Web Push, by contrast, is now universal — iOS Safari included (for installed PWAs) — and the worker receives push events with no page open. Always show a notification for user-visible pushes; silent push abuse gets your subscription throttled.
Scope and Communication
A worker controls pages under its scope, which defaults to the script's directory — /js/sw.js cannot control /. Serve the worker from the root (or send a Service-Worker-Allowed header) and register with an explicit { scope: '/' } to avoid the classic "registered fine, intercepts nothing" confusion. Service workers also require HTTPS everywhere except localhost.
Page and worker communicate by message passing: registration.active.postMessage(data) from the page, client.postMessage(data) from the worker after looking up clients with self.clients.matchAll(). Keep the protocol tiny — a couple of message types for "skip waiting now" and "outbox flushed" covers most apps — and remember the worker can be killed between events, so persistent state belongs in IndexedDB, never in worker globals.
Common Mistakes
- Caching /index.html with cache-first — users get a permanently stale app shell; HTML is network-first, always
- Forgetting cache cleanup in activate — quota fills with orphaned versions until eviction wipes *everything*
- Testing with DevTools "Update on reload" enabled, then shipping and discovering real update behavior for the first time in production
- Intercepting requests you add no value to — pass third-party and range requests straight through
- Assuming cache writes are durable — storage is best-effort unless you request navigator.storage.persist()
Workbox or Hand-Rolled
Workbox encodes years of these edge cases: routing, strategy implementations, precache manifest generation, broken-download protection. For an app with real offline requirements, it pays for itself. For a 50-line "cache the shell and go" worker, hand-rolling keeps the mental model honest. Either way, read your own generated worker once — deploys that silently serve stale bundles are the genre's signature outage.
Install Experience
Chromium fires beforeinstallprompt; stash the event, show your own affordance after meaningful engagement, then call prompt() on it. iOS still installs via Share → Add to Home Screen with no event, so surface instructions there. A correct manifest (icons, display: standalone, theme colors) plus an offline-capable worker is what elevates the app from bookmark to install candidate.