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
fetchfrom 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:
- Register — page tells the browser “use this script as my SW.”
- Install — SW downloads, runs install handler (cache the app shell).
- Activate — SW takes control (after old SW unmounts). Clean up old caches here.
- 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:
skipWaiting()in install handler — new SW activates immediately:self.addEventListener("install", (event) => { event.waitUntil(precache()); self.skipWaiting(); });clients.claim()in activate handler — new SW takes control of existing pages:self.addEventListener("activate", (event) => { event.waitUntil(clients.claim()); });- 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:
- User grants permission (
Notification.requestPermission()). - App subscribes to push via SW:
registration.pushManager.subscribe(...)with VAPID public key. - Server stores the subscription, sends push messages signed with VAPID.
- Browser delivers to SW; SW’s
pushevent fires; SW callsself.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. fetchfrom SW lacks browser context — no cookies sent by default. Addcredentials: "include"if needed.Rangerequests (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;
beforeinstallpromptto 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
- Storage APIs (where SW caches live): 04_storage_apis.md
- Notification center design: ../14_frontend_system_design/08_notification_center.md
- HTTP caching headers (interacts with SW): 08_caching_headers.md
Further reading
- MDN — Service Worker API: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
- Workbox: https://developer.chrome.com/docs/workbox
- web.dev — PWAs: https://web.dev/explore/progressive-web-apps
- “Offline Cookbook” — Jake Archibald: https://web.dev/articles/offline-cookbook