Frontend / Browser internals / 05_service_workers_pwa.md

Service Workers and PWAs

Updated 5 interview angles 9 min read source
On this page6
  1. Service Worker
  2. PWA
  3. Gotchas
  4. Related
  5. Further reading
  6. Interview angle

Service Workers and PWAs

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

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

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.

Lifecycle — register, install, activate, fetch.

js
// In the page
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js", { scope: "/" });
}

The worker script

js
// 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()).

Caching strategies — which to pick?

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
js
// 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;
      });
      // serve cache if present, network fills in
      return cached ?? fetchPromise;
    })
  );
});

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

The classic update bug — what happens?

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:
    js
    self.addEventListener("install", (event) => {
      event.waitUntil(precache());
      self.skipWaiting();
    });
  2. clients.claim() in activate handler — new SW takes control of existing pages:
    js
    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.

SW scope.

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

text
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.

How do you debug a service worker?

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.

When NOT to use a service worker?

  • 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

What’s a PWA, concretely?

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.

manifest.json essentials.

json
{
  "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).

Install prompts and beforeinstallprompt.

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

js
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.

Web Push for notifications.

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 Design: Notification Center for the full design.

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

Background sync — when use?

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

js
// 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

  • 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.

Further reading

Interview angle 5

  • “What is a service worker?” - a background JavaScript context with no DOM, sitting between the page and the network as a programmable proxy. It is what makes offline, app-shell caching, push notifications and background sync possible.
  • “Walk through the lifecycle.” - install, where you precache; activate, where you clean up old caches; then fetch, where you intercept requests. The part that surprises people is that a new worker waits until every page using the old one is gone - so a deploy does not take effect on a refresh.
    • Follow-up: “How do you ship an update, then?” - skipWaiting() and clients.claim() to take over immediately, paired with a “refresh to update” prompt. Doing it silently mid-session can swap assets under a running page, which produces failures that are very hard to reproduce.
  • “Would you write the caching logic yourself?” - no. Workbox gives you routing and the standard strategies - stale-while-revalidate, network-first, cache-first - and hand-rolling them produces subtly wrong cache invalidation that users experience as stale content they cannot clear.
  • “What makes something a PWA?” - HTTPS, a manifest, a service worker, and a responsive layout. beforeinstallprompt lets you offer a custom install button, though not on iOS Safari, where installation is a browser menu action you cannot trigger.
  • “When should you not add a service worker?” - when there is no offline or repeat-visit benefit. The update model adds real complexity and a class of “why am I seeing an old version” bugs, so it should be paying for something - news, messaging, maps, commerce - rather than being added because it is available.