Back to Browser Internals
Browser Internals
medium
mid

What are service workers and how do they enable PWA features?

A service worker is a background JS thread that intercepts network requests for an origin. Pair with the Cache API to serve responses offline, push notifications, and background sync. Lifecycle: install → activate → fetch.

8 min read·~25 min to think through

A service worker is a JS file the browser runs in a separate thread, scoped to an origin, that can intercept and respond to network requests. It's the engine behind Progressive Web Apps: offline support, push notifications, install-to-home-screen, background sync.

Lifecycle (interviewers love this).

  1. Register. navigator.serviceWorker.register("/sw.js", { scope: "/" }) from the page. Scope determines which paths the SW controls. Default scope is the location of the SW file — keep it at the root for site-wide control.
  2. Install. self.addEventListener("install", e => e.waitUntil(precache())). Pre-cache critical assets. Promise resolved → installed.
  3. Activate. Old SW (if any) is being replaced. Clean up old caches here. The new SW does not control existing pages until they reload, unless you call self.skipWaiting() (in install) and clients.claim() (in activate).
  4. Fetch. Every navigation and asset request fires fetch on the SW. You can respond with cache, network, or a synthesized response.

The Cache API. A key-value store of RequestResponse, scoped to the origin and named by you. Independent of HTTP cache. Survives reloads.

Caching strategies (Workbox names them).

  • Cache-first. Look in cache; if hit, return it; else fetch and cache. Best for hashed static assets.
  • Network-first. Fetch; on failure, fall back to cache. Best for HTML / API where freshness matters.
  • Stale-while-revalidate. Return cache immediately, fetch in background, update cache. Best for "fast and eventually fresh" — most app shell content.
  • Network-only / Cache-only. Self-explanatory. Useful for analytics (network-only) or offline-only assets (cache-only).
ts
// sw.js — stale-while-revalidate
self.addEventListener("fetch", (event) => {
  if (event.request.method !== "GET") return;
  event.respondWith((async () => {
    const cache = await caches.open("v1");
    const cached = await cache.match(event.request);
    const networkPromise = fetch(event.request).then(resp => {
      if (resp.ok) cache.put(event.request, resp.clone());
      return resp;
    }).catch(() => undefined);
    return cached ?? (await networkPromise) ?? new Response("Offline", { status: 503 });
  })());
});

PWA features it unlocks.

  • Offline. Pre-cache the app shell; fall back to a cached offline page for navigations.
  • Install to home screen. Web App Manifest (manifest.json) + HTTPS + a registered SW = installable. Browser shows the install prompt.
  • Push notifications. SW listens for push events and shows notifications even when the page is closed.
  • Background sync. registration.sync.register("upload") retries network work when the user comes back online (Chromium only at the moment).
  • Periodic background sync (Chromium): refresh data periodically.

Critical gotchas.

  • HTTPS only (except localhost). Service workers can hijack requests, so they require a secure context.
  • Updates are sticky. A new SW waits until all clients close the old one. Use skipWaiting + clients.claim carefully — can update mid-session and break.
  • Cache versioning is your job. When you change the SW, bump the cache name (v1v2) and delete old caches in activate — otherwise old assets persist forever.
  • fetch runs for every request. Heavy logic in there can slow page loads. Keep handlers lean.
  • No DOM access. SW is a Worker — no window, no document. Communicate with pages via postMessage and MessageChannel.
  • Range requests / video behave specially — pass through to network; don't try to cache.

Use Workbox in production. Google's library wraps all the strategies, cache versioning, and precaching. Don't hand-write SWs unless you need exotic behavior.

When NOT to use a SW. Static marketing sites that the HTTP cache already handles. Anything where install bugs (sticky old caches) would be catastrophic for low-engagement users. Always have an "unregister" escape hatch.

Code

ts
const CACHE = "app-v3";
const PRECACHE = ["/", "/offline.html", "/styles.css", "/app.js"];

self.addEventListener("install", (e) => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(PRECACHE)).then(() => self.skipWaiting()));
});

self.addEventListener("activate", (e) => {
  e.waitUntil((async () => {
    const keys = await caches.keys();
    await Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)));
    await self.clients.claim();
  })());
});
Install + activate with versioned cache cleanup

Follow-up questions

  • How do you handle SW updates without breaking a logged-in user mid-session?
  • When would you choose stale-while-revalidate over network-first?
  • How would you debug a 'won't update' SW issue?
  • What does the Web App Manifest contribute beyond the SW?

Common mistakes

  • Forgetting to bump the cache name on deploy — old assets persist.
  • Calling skipWaiting + clients.claim without thought — mid-session asset swap can break the page.
  • Trying to cache POST or non-GET requests — Cache API only handles GET.
  • Putting heavy logic in the fetch handler — slows every request.

Performance considerations

  • Pre-cache only the shell (HTML/CSS/JS); lazy-cache the rest.
  • Stale-while-revalidate is the highest-perceived-perf strategy for repeat visits.

Edge cases

  • Range requests (video) — bypass the SW.
  • Auth flows that redirect — careful caching the HTML can break SSO.
  • Browser private mode disables SW persistence.

Real-world examples

  • Twitter Lite, Pinterest, Starbucks PWA, Notion's offline mode — all SW + Cache API.

Senior engineer discussion

Senior signal: lifecycle correctness, cache versioning hygiene, choosing strategy per resource type, and knowing Workbox exists.