GAP·MAP

PWAs & service workers

[ middle depth ]

The service worker lifecycle in order

A service worker is a script that runs on its own thread as a network proxy between your page and the network. It has no DOM access, it is event-driven, and the browser kills it when idle and restarts it on an event, so you cannot keep state in globals between events. Persist state in the Cache API or IndexedDB, never in localStorage (it is synchronous and not available inside a worker).

Registration runs the script and moves it through fixed states:

// install fires once per worker version: precache the shell here
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("v1").then((c) => c.addAll(["/", "/index.html", "/app.js"]))
  );
});
// activate fires once the old worker controls no clients: clean old caches
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== "v1").map((k) => caches.delete(k)))
    )
  );
});

event.waitUntil() in install signals success: if its promise rejects, install fails and the worker is discarded. Note that cache.addAll rejects if any URL returns a non-2xx status, so one 404 fails the whole precache.

Why your first load is not controlled

The load that registers a worker is not controlled by it. navigator.serviceWorker.controller is null on that load, and your fetch handler does not run for it. A worker only controls navigations that happen after it activates, which normally means the next reload.

Wrong "I registered the worker, so this page is now going through it." It is not, until you either reload into a controlled page or call self.clients.claim() in the activate handler to take control of the already-open tab.

Choosing a caching strategy per route

The Cache API ignores HTTP cache headers and entries never expire on their own, so you own versioning. Match the strategy to the resource:

  • Cache-first for hash-versioned static assets (JS, CSS, fonts): fast and offline, safe because the URL changes when the content does.
  • Network-first for HTML and API responses you want fresh but still available offline. The cost is that on a flaky connection the user waits for the network to time out before the cache answers.
  • Stale-while-revalidate for content that should be instant and only roughly fresh (avatars, non-critical data): serve the cached copy now, refresh the cache in the background for next time.

One recurring bug: a Response body is a one-time stream, so you must .clone() it before both returning it and putting it in the cache.

Why your update did not go live

You redeploy /sw.js, reload the tab, and nothing changes. The old worker still controls open tabs, and the new one waits until every tab it controls is closed or navigated away. A single same-tab reload is usually not enough, because the old page and its controller survive the refresh. The reliable fix is to detect the waiting worker, prompt the user, call skipWaiting(), and reload. Serve HTML network-first, not cache-first, or the stale shell keeps pointing at old assets and users never see the update.

[ senior depth ]

The waiting state and safe updates

Only one worker version controls a page at a time. A redeployed worker installs, then sits in the waiting state while the old worker keeps controlling open tabs. It activates only once every client of the old worker is closed or navigated away. A same-tab reload rarely does it: during the refresh the old page and its controller stay alive until the new response arrives, so the reload is still served by the old worker.

The safe update UX is prompt-then-reload. Detect the new worker through updatefound and statechange, show a "new version, reload" toast, and message the waiting worker to promote itself:

// in the page
reg.waiting?.postMessage({ type: "SKIP_WAITING" });
navigator.serviceWorker.addEventListener("controllerchange", () => {
  if (refreshing) return; // guard: controllerchange can fire more than once
  refreshing = true;
  window.location.reload();
});
// in the worker
addEventListener("message", (e) => {
  if (e.data?.type === "SKIP_WAITING") self.skipWaiting();
});

skipWaiting and clients.claim, and why they bite

skipWaiting() activates a waiting worker without waiting for old clients to close. clients.claim() (called in activate) makes a newly activated worker take control of already-open uncontrolled tabs. They solve different problems and neither is free.

The danger is activating a new worker under a still-running old page. That old page can lazy-load a code-split chunk by its old hashed filename, which the new deploy no longer serves, so it 404s until the tab reloads. In-flight requests started by the old page can hit the new worker's fetch logic. This is the argument for prompt-then-reload over a blind skipWaiting() plus clients.claim(), which is only low-risk when navigation/HTML requests use a network-first (or network-only) strategy and you do not precache the shell.

Wrong "Changing the worker filename per release, like sw-v1.js to sw-v2.js, is a clean way to version it." If the old shell is served cache-first, it still points at sw-v1.js, so sw-v2.js is never requested and the app is stuck on the old worker. Keep a single stable /sw.js and version your caches instead.

How the browser decides sw.js is new

Update detection is byte-diff based: a byte-identical redeploy triggers no install. The browser checks for a new script on every navigation to an in-scope page, and on push/sync events unless there has already been an update check in the last 24 hours; registration.update() forces one. The script's own HTTP max-age is capped at 24 hours, and by default (updateViaCache: "imports") the top-level script is fetched from the network on update checks while imported scripts may use the HTTP cache. Practical rule: never serve sw.js with a long cache lifetime, and ship an unregister-and-clear-caches worker as a kill-switch, since a bad worker can cache itself and brick the origin.

Offline, background sync, and push boundaries

Offline support is precache-the-shell at install plus per-route strategies plus a generic offline.html fallback when both cache and network fail. Two deferral APIs get confused: one-off Background Sync (reg.sync.register(tag)) replays a failed action once when connectivity returns and retries with backoff if waitUntil rejects; Periodic Background Sync (reg.periodicSync.register) runs on a recurring minInterval and needs an installed PWA. Both are Chromium-only, and minInterval is a floor the browser can exceed based on engagement and battery. Push is a third path: pushManager.subscribe needs userVisibleOnly: true in Chrome (omitting it throws) plus the VAPID key, the push service wakes the worker for the push event even when the app is closed, and the Push API only delivers the message. You still call self.registration.showNotification() to display it.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Browser & Network

was this useful?