Back to Blog
PWA2026-04-22

Service Workers and PWAs in 2026

Build offline-first apps with service workers, Cache API, and Background Sync.

Service workers turn web apps into installable, offline-capable software. The API stabilized years ago, but the patterns keep evolving — and the failure modes (stale HTML served forever, broken updates, blown storage quotas) are all self-inflicted. Here is the 2026 playbook.

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.