frontend / browser internals / 05_service_workers_pwa.md

Service Workers and PWAs

8 min read source

Service Workers and PWAs

TL;DR

A service worker is a JavaScript file that runs in the background, separate from a page, with no DOM access. It sits between your page and the network — can intercept fetches, serve from cache, push notifications, sync in the background. PWA (Progressive Web App) is the application of service workers + manifest + offline capabilities to make a web app feel installable and reliable. Senior topics: lifecycle (install → activate → fetch), caching strategies, update gotchas (“the user is on the old version”), scope rules, and debugging.

Service Worker Q&A

Q: What’s a service worker, and what can it do?

A: A background JS context with:

  • Network intercept — handle every fetch from the page; serve from cache or modify the response.
  • Cache control — manage the Cache API.
  • Push notifications — receive server-sent push messages even when the tab is closed.
  • Background sync — defer requests until the device has connectivity.
  • No DOM — can’t access document, window, localStorage (uses IndexedDB instead).
  • Event-driven, terminated when idle — runs until done, then sleeps.

Used for: offline support (serve cached responses), app-shell caching (HTML/CSS/JS instant on repeat visit), media caching, push, background sync.

Q: Lifecycle — register, install, activate, fetch.

A:

// In the page
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js", { scope: "/" });
}
// sw.js
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("v1").then((cache) =>
      cache.addAll(["/", "/index.html", "/app.css", "/app.js"])
    )
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter(k => k !== "v1").map(k => caches.delete(k)))
    )
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => cached ?? fetch(event.request))
  );
});

Lifecycle:

  1. Register — page tells the browser “use this script as my SW.”
  2. Install — SW downloads, runs install handler (cache the app shell).
  3. Activate — SW takes control (after old SW unmounts). Clean up old caches here.
  4. Fetch — for every network request from controlled pages, SW handles or passes through.

Important: a newly-installed SW doesn’t control existing pages until they reload (or skipWaiting() + clients.claim()).

Q: Caching strategies — which to pick?

A:

Strategy Behavior Use for
Cache only always serve from cache static assets (versioned via hash)
Network only always fetch dynamic data, mutations
Cache, fall back to network try cache first, fetch on miss app shell (HTML/CSS/JS)
Network, fall back to cache try network first, cache on offline content (articles, posts)
Stale-while-revalidate serve cache immediately, fetch + update cache in background feeds, lists (fresh-ish UX)
Network with cache update fetch + cache the response mixed-fresh
// Stale-while-revalidate
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      const fetchPromise = fetch(event.request).then((response) => {
        caches.open("v1").then((cache) => cache.put(event.request, response.clone()));
        return response;
      });
      return cached ?? fetchPromise;   // serve cache if present, network fills in
    })
  );
});

Workbox (Google library) provides all of these as one-liners — usually preferred over hand-rolling.

Q: The classic update bug — what happens?

A: User loads page → SW v1 caches assets → you deploy v2 → user revisits.

Default behavior:

  • Browser fetches /sw.js, sees it changed.
  • New SW (v2) installs in the background.
  • New SW stays in “waiting” state until all controlled pages unmount.
  • User keeps using v1 until they fully close the tab and reopen.

Result: users see old code until they close the tab. Frustrating for fast iteration.

Fixes:

  1. skipWaiting() in install handler — new SW activates immediately:
    self.addEventListener("install", (event) => {
      event.waitUntil(precache());
      self.skipWaiting();
    });
  2. clients.claim() in activate handler — new SW takes control of existing pages:
    self.addEventListener("activate", (event) => {
      event.waitUntil(clients.claim());
    });
  3. Prompt the user to refresh — show a banner when a new SW is waiting, user clicks → postMessage({ type: 'SKIP_WAITING' })self.skipWaiting() → page reload.

The “prompt to refresh” pattern is the safest — users don’t get surprise reloads, and you don’t risk hot-swapping JS/CSS that may conflict with the in-memory state.

Q: SW scope.

A: A SW controls all pages at or below its registration path.

SW registered at /sw.js     → controls /, /about, /users/123, etc.
SW registered at /app/sw.js → controls only /app/* paths

You can register multiple SWs per origin (one per scope), but managing more than one is painful.

To register a SW at a subpath: the SW script must be served from that subpath or above, and the response must include Service-Worker-Allowed: <scope> header to permit broader scope than the script path.

Common pattern: serve /sw.js from the root; scope is /; one SW for the whole app.

Q: How do you debug a service worker?

A: Chrome DevTools → Application tab → Service Workers:

  • Status — installed, activated, idle, etc.
  • Update on reload — checkbox; forces SW to update every reload (essential for dev).
  • Bypass for network — turn off SW for testing.
  • Unregister — kill the SW entirely.

For network behavior, DevTools → Network tab — requests served from the SW show “(ServiceWorker)” instead of the network. Use this to verify caching is firing.

For SW console logs and errors: separate context — open via DevTools → Application → Service Workers → click the SW name.

Q: When NOT to use a service worker?

A:

  • Apps that don’t benefit from offline. If the user is always online and uses the app once-per-week, SW caching adds complexity for nothing.
  • Apps with rapid iteration — the update model causes “user is on yesterday’s version” bugs. The refresh-prompt pattern works but is overhead.
  • Apps with sensitive data — caching responses with personal data means it lives on the user’s disk indefinitely until evicted.

When to use:

  • News / blog sites — instant repeat-visit load (app shell cached).
  • Email / messaging — works offline (read queued messages, draft new ones).
  • Maps / docs — large download cached for repeat use.
  • E-commerce — fast browsing on flaky mobile.

PWA Q&A

Q: What’s a PWA, concretely?

A: A web app that meets a baseline of installability + reliability:

  • HTTPS (or localhost).
  • manifest.json — name, icons, start URL, theme color, display mode.
  • Registered service worker — at least one fetch handler.
  • Responsive design that works on mobile.

When these are met, browsers show an “Install” prompt; the user can add to home screen. Installed PWAs launch in their own window (no browser chrome) and feel native-ish.

Q: manifest.json essentials.

A:

{
  "name": "My App",
  "short_name": "MyApp",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#fff",
  "theme_color": "#000",
  "description": "My app description",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "shortcuts": [
    { "name": "Dashboard", "url": "/dashboard", "icons": [...] }
  ],
  "screenshots": [...]
}

Linked from HTML: <link rel="manifest" href="/manifest.json">.

display: "standalone" removes browser chrome when installed. display: "fullscreen" removes the OS status bar too. display: "browser" keeps everything (no point).

Q: Install prompts and beforeinstallprompt.

A: The browser fires beforeinstallprompt when install criteria are met. Capture it to show a custom install button:

let deferredPrompt: any;

window.addEventListener("beforeinstallprompt", (e) => {
  e.preventDefault();
  deferredPrompt = e;
  installBtn.style.display = "block";
});

installBtn.addEventListener("click", async () => {
  if (!deferredPrompt) return;
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  console.log(outcome);   // "accepted" or "dismissed"
  deferredPrompt = null;
});

iOS Safari doesn’t fire beforeinstallprompt — users add to home screen manually via the share menu. Detect iOS, show explicit instructions.

Q: Web Push for notifications.

A: PWA push notifications flow:

  1. User grants permission (Notification.requestPermission()).
  2. App subscribes to push via SW: registration.pushManager.subscribe(...) with VAPID public key.
  3. Server stores the subscription, sends push messages signed with VAPID.
  4. Browser delivers to SW; SW’s push event fires; SW calls self.registration.showNotification(...).

See ../14_frontend_system_design/08_notification_center.md for the full design.

userVisibleOnly: true is required by Chrome — no silent push.

Q: Background sync — when use?

A: Background Sync API lets a SW retry a task when the user has connectivity:

// In page
async function scheduleSync() {
  const reg = await navigator.serviceWorker.ready;
  await reg.sync.register("upload-queue");
}

// In SW
self.addEventListener("sync", (event) => {
  if (event.tag === "upload-queue") {
    event.waitUntil(uploadQueuedItems());
  }
});

The browser fires the sync event when connectivity returns. Useful for “queue this action while offline, fire it later.”

Limited browser support (Chrome+Edge well, Firefox+Safari not yet). For broad coverage, manual queue + retry on online event.

Gotchas / edge cases

  • Workbox is the right default — hand-rolling SW logic from scratch is error-prone. Workbox handles strategies, precaching, routing.
  • Chunk-hash invalidation + SW caching can collide — SW caches /app.[oldhash].js; new build emits /app.[newhash].js; old chunk 404 on a user who hasn’t refreshed. Pair SW versioning with hashing.
  • SW updates check happens on navigation — if your app is a SPA where users don’t navigate (no full reload), the SW may never update. Force registration.update() periodically.
  • fetch from SW lacks browser context — no cookies sent by default. Add credentials: "include" if needed.
  • Range requests (media streaming) need explicit SW handling — event.respondWith(fetch(event.request)) passes through; caching ranges is tricky.
  • SW failing silently — install/activate errors don’t bubble to the page by default. Wrap in try/catch + log to your error tracker.
  • iOS Safari PWA limits — no push (until recently and with caveats), 50MB cache cap, install flow different.

What a senior is expected to say

  • “Service workers are a background JS context with no DOM, sitting between page and network. Used for offline, app-shell caching, push, background sync.”
  • “Lifecycle: install (precache) → activate (cleanup old caches) → fetch (intercept). A new SW waits until pages unmount unless you skipWaiting() + clients.claim() — usually pair with a ‘refresh to update’ user prompt.”
  • “Use Workbox for routing + caching strategies — stale-while-revalidate, network-first, cache-first. Hand-rolling is error-prone.”
  • “PWA = HTTPS + manifest.json + SW + responsive. Installed PWAs launch standalone; beforeinstallprompt to show a custom install button (not on iOS Safari).”
  • “Web Push needs SW + VAPID + userVisibleOnly: true. iOS support is limited; design notifications as enhancement, not requirement.”
  • “Don’t use SW for apps without offline benefit — the update model adds complexity. Use for news/messaging/maps/e-commerce where repeat-visit speed and offline matter.”

Cross-references

Further reading