Guide

Service workers and PWA explained: offline caching and installable web apps

A progressive web app (PWA) is a website that behaves more like a native application: it can load when the network is flaky, sit on the home screen with its own icon, and in some cases send push notifications. The engine behind most of that capability is the service worker — a JavaScript file that runs in a separate thread from your page, intercepts network requests, and decides whether to serve cached bytes or fetch fresh ones. This guide walks through registration and lifecycle, the caching strategies that actually work in production, how the web app manifest makes a site installable, and the security and update pitfalls that trip up first-time PWA builders.

What a service worker does (and does not do)

Normal page JavaScript dies when the tab closes. A service worker persists across sessions (until you unregister it) and listens for events even when no page is open. Its primary hook is fetch: every HTTP request from pages in its scope passes through the worker first. You can return a cached response instantly, go to the network, or combine both.

Service workers cannot access the DOM directly. They communicate with pages via postMessage. They also cannot use synchronous APIs — everything is promise-based. That separation is deliberate: a slow or crashing worker must not freeze the UI thread.

HTTPS is mandatory on real domains (localhost is exempt for development). Without TLS, anyone on the network could swap your worker script and hijack every request your app makes — which is why browsers refuse registration on plain HTTP. Pair worker deployment with solid TLS and HTTPS hygiene before worrying about offline polish.

Lifecycle: install, activate, fetch

Registration starts from your main page:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js', { scope: '/' });
}

Three phases matter:

  • Install — fires once per new worker version. Use event.waitUntil() to open caches and cache.addAll() critical shell assets (HTML shell, CSS, JS, fonts, offline fallback page). If any URL in addAll 404s, the whole install fails.
  • Activate — the new worker takes control. Delete obsolete cache names here so old versions do not fill disk. Until activation completes, the old worker may still serve fetches.
  • Fetch — runs on every scoped request. Implement your caching strategy in event.respondWith().

Updates are conservative by design. When you change sw.js, the browser downloads the new file and puts it in a waiting state. It does not activate until all tabs using the old worker close — unless you call skipWaiting() on install and clients.claim() on activate to take over immediately. Aggressive skip-waiting can surprise users mid-session with stale-in-memory state; many apps show a "New version available — refresh" banner instead.

Caching strategies that survive production

The Cache API (caches.open('v1')) stores Request/Response pairs. It is separate from HTTP Cache-Control headers and from localStorage or IndexedDB — use the right store for the job. Static assets belong in the Cache API; structured user data belongs in IndexedDB.

Cache-first (app shell)

Check cache, fall back to network. Best for fingerprinted JS/CSS/fonts that never change URL without a version bump. Returns instantly on repeat visits. Risk: serving ancient HTML if you cache /index.html without a network revalidation path.

Network-first (fresh API data)

Try network, fall back to cache on failure. Good for JSON feeds and account dashboards where staleness must be bounded. Add a timeout (e.g. 3 seconds) so slow networks do not hang forever before hitting cache.

Stale-while-revalidate

Return cached copy immediately, fetch an update in the background, and replace cache for next time. Excellent for images and semi-static content. Users see fast paint; content converges toward fresh within one navigation. This pattern mirrors what CDNs do at the edge with stale-while-revalidate headers — you are reimplementing similar logic client-side for offline resilience.

Network-only and cache-only

Payment endpoints, auth tokens, and wallet RPC calls should be network-only — never cache sensitive or non-idempotent responses. Precached build artifacts can be cache-only after install. Mixing strategies per route (via URL pattern matching) is normal in real PWAs.

The web app manifest

Caching alone does not make a site installable. You also need a manifest.webmanifest linked from HTML:

<link rel="manifest" href="/manifest.webmanifest">

Key fields:

  • name and short_name — launcher label and home-screen text.
  • start_url — entry point when opened from icon (include a query param like ?source=pwa for analytics).
  • displaystandalone hides the browser chrome; minimal-ui keeps back/refresh; browser is a normal tab.
  • icons — at minimum 192x192 and 512x512 PNGs; maskable icons for Android adaptive icons.
  • theme_color and background_color — splash screen and status bar tint during load.

Browsers surface an install prompt when manifest + service worker + engagement heuristics pass (Chrome requires HTTPS, a manifest, a registered worker, and some user interaction). iOS Safari supports "Add to Home Screen" manually but does not fire the same beforeinstallprompt event — test on both platforms. Installed PWAs on desktop Chrome open in their own window without the address bar, which changes how users perceive trust; keep branding consistent with your main site.

Offline UX beyond a blank screen

Precache a dedicated /offline.html and serve it from the fetch handler when both cache and network fail for navigation requests. Distinguish "you are offline" from "this page does not exist" — a generic 404 confuses users on airplanes.

For read-heavy apps, queue failed POST requests in IndexedDB and replay them when navigator.onLine flips true or on a sync event (Background Sync API — Chromium-heavy, not universal). Show pending state in the UI so users know their action will complete later. Financial and wallet flows should not silently queue payments; require explicit user confirmation before retry.

Measure perceived performance with Core Web Vitals. A PWA that caches a 2 MB bundle may paint fast on repeat visits but fail LCP on first install when the worker downloads the entire precache list. Split precache into critical shell vs lazy on-demand caches.

Push notifications and background tasks

Push requires a service worker push event handler and user permission. The server sends encrypted payloads via Web Push (VAPID keys); the worker displays a notification with registration.showNotification(). Clicks route through notificationclick to focus or open a URL.

Push is powerful and easy to abuse. Ask permission in context (after the user enables alerts), not on first page load. Unsubscribe paths must work. Many jurisdictions treat marketing push like email — consent and opt-out matter.

Periodic Background Sync and the Payment Handler API exist for niche cases; check caniuse.com before building product requirements around them. Feature-detect and degrade gracefully to in-tab behavior.

Workbox vs hand-rolled workers

Google's Workbox library encodes battle-tested recipes: precaching with revision hashes, runtime caching by route, broadcast updates to clients, and integration plugins for bundlers (Vite, webpack). It reduces boilerplate and avoids subtle cache-key bugs.

Hand-rolled sw.js files make sense for tiny static sites with a dozen assets. Above that, Workbox or framework plugins (Next.js PWA, Vite PWA) save time. Whatever you choose, version your cache names (static-v3) and delete old names on activate. Orphaned caches are a top cause of "my deploy did not show up" support tickets.

Content Security Policy affects workers too. If your CSP blocks worker scripts or limits connect-src, registration fails silently in the console. Test with CSP report-only before enforcing.

PWA vs native app: a honest comparison

PWAs win on distribution (no app store review), instant updates, one codebase for all platforms, and SEO for public content. They struggle where OS integration is deep: Bluetooth LE quirks, background geofencing, complex 3D games, and some biometric APIs remain native-first.

For content sites, dashboards, lightweight tools, and wallet-adjacent flows that already live in the browser, a PWA layer is often the right incremental step — especially if your base site is static or SSG/SSR with fingerprinted assets. For a game needing 60 fps and Gamepad API, optimize the game loop first; offline caching of WASM bundles is viable, but the worker does not replace a performant render path.

Common mistakes

  • Caching index.html cache-first without a network update path — users stuck on week-old builds.
  • Precaching API responses that include personal data — shared device leak.
  • Forgetting to bump cache version strings on deploy — activate runs but old assets remain.
  • Calling skipWaiting() without UI notice — in-flight form submissions break.
  • Assuming navigator.onLine means the server is reachable — it only reflects a network interface.
  • Registering a worker on every page without checking for updates — use registration.update() on focus.
  • Shipping a 5 MB precache list on first visit — kills mobile first-load despite great repeat visits.

Starter checklist

  • Serve everything over HTTPS with valid certificates.
  • Create manifest with icons, standalone display, and start_url.
  • Register worker; handle install (precache shell), activate (prune old caches), fetch (route strategies).
  • Cache-first for hashed static assets; network-first for API; never cache auth or payment POSTs.
  • Add offline fallback page for navigation requests.
  • Prompt for refresh when a new worker is waiting — do not force silent takeover.
  • Test install flow on Chrome Android, desktop, and iOS Add to Home Screen.
  • Audit cache size in DevTools Application panel after each release.

Related reading