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 andcache.addAll()critical shell assets (HTML shell, CSS, JS, fonts, offline fallback page). If any URL inaddAll404s, 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:
nameandshort_name— launcher label and home-screen text.start_url— entry point when opened from icon (include a query param like?source=pwafor analytics).display—standalonehides the browser chrome;minimal-uikeeps back/refresh;browseris a normal tab.icons— at minimum 192x192 and 512x512 PNGs; maskable icons for Android adaptive icons.theme_colorandbackground_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.htmlcache-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.onLinemeans 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,
standalonedisplay, andstart_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
- HTTP caching explained — Cache-Control and CDN headers that complement service workers
- Browser storage explained — cookies, localStorage, and IndexedDB for offline data
- Core Web Vitals explained — measuring load and responsiveness after adding a worker
- All Solana Garden guides