Service Workers — Lifecycle, Install/Activate, Fetch & Clients
A Service Worker is an origin-scoped network proxy. Register, install, activate, skipWaiting, clients.claim, fetch, and scope explain real update bugs.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Does a Service Worker run on the page main thread?
Answer
No. It is a separate worker with its own event loop. The browser can stop it when idle and wake it for fetch, push, or sync.
L2
What is the order of install, waiting, and activate?
Answer
The new worker installs. If an old worker still controls clients, the new one waits. Activate runs when it is allowed to take control, or sooner after skipWaiting.
L3
What does clients.claim change?
Answer
Activate alone does not seize pages the old worker still controls. Claim makes the new worker the controller of those clients immediately.
L4
Why does one 404 during install block the new worker?
Answer
cache.addAll is atomic. One missing precache URL rejects install, so the previous worker stays in control.
L5
Why is scope a fetch bug?
Answer
The maximum scope is the worker script's directory. A worker at /app/sw.js does not see / unless Service-Worker-Allowed widens it.
L6
What must a fetch handler never cache?
Answer
Non-GET by default, failed responses, and personalized or Authorization-bearing responses in a shared cache.
L7
How do you ship an update without a half-upgraded UI?
Answer
Version the cache, delete unknown names on activate, postMessage open clients, and reload after the user accepts. Do not skipWaiting blindly.
Failure modes
Worker never updates
sw.js bytes did not change, HTTP cache held the script, or a waiting worker was never activated.
Precache 404
addAll fails the install. The old worker remains the controller.
Cached authenticated API
User A's JSON is stored under a URL user B will match.
Scope too narrow
Navigations outside the worker directory bypass fetch.
skipWaiting without a reload
Old HTML keeps running against new fetch rules.
respondWith throws
The navigation becomes a network error. Always return a Response, including an offline fallback.
Misconceptions
The worker is another copy of the page.
It has no DOM. It talks to windows through Clients and postMessage.
Activate updates every open tab.
Without clients.claim, already-controlled clients keep the old worker until they navigate.
Registration means fetch is intercepted.
Only a controlling worker sees fetch. The first visit often does not control the page that registered it.
Interviewer traps
Quoting skipWaiting as always correct.
Pair it with a visible update and a reload. Name the mixed-version failure.
Describing Redis or the HTTP cache as the worker cache.
Cache Storage is the Cache API. The HTTP cache is a different layer. Redis is a server.
A new worker is installed while tabs are open
Prefer
Versioned cache and a visible update
Let the worker wait, tell the page, then skipWaiting and reload when the user accepts.
- Open tabs keep one JS generation until reload.
- Activate deletes cache names you no longer list.
- The toast is the product, not an afterthought.
Alternative
skipWaiting on every install
The new worker activates immediately and can claim clients that still run yesterday's bundle.
- Updates land without closing tabs.
- Fetch rules and page JS can disagree mid-session.
- Users see a half-upgraded UI and file a bug you cannot reproduce.
From register to a controlled fetch
The diagram is the same sequence, stacked for a narrow card.
- 1
Register sw.js
HTTPS or localhost. Scope defaults to the script directory. - 2
Install
Precache the shell. One failed addAll rejects the worker. - 3
Wait if needed
An old controller keeps the new worker in waiting until tabs go or you skipWaiting. - 4
Activate
Delete caches outside the allowlist. Claim only if you mean to. - 5
Fetch
Controlled clients only. Return a Response or the navigation fails.
Overview
A Service Worker is an origin-scoped network proxy with its own lifetime: register, install, waiting, activate, then fetch for clients it controls. skipWaiting, clients.claim, and scope dominate production update bugs.
It is not a second copy of the page. It can be stopped when idle and woken by events. The first successful registration usually does not control the page that called register. Control starts on the next navigation, unless you claim.
States that matter
Flow
- 1
1. Page registers sw.js
- next2. Install precaches the shell
- 2
2. Install precaches the shell
- next3. Wait if an old worker controls
- 3
3. Wait if an old worker controls
- next4. Activate deletes old caches
- 4
4. Activate deletes old caches
- next5. Fetch serves cache or network
- 5
5. Fetch serves cache or network
Lesson map
Service Workers — Lifecycle, Install/Activate, Fetch & Clients
A Service Worker is an origin-scoped network proxy. Register, install, activate, skipWaiting, clients.claim, fetch, and scope explain real update bugs.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["1. Page registers sw.js"] b["2. Install precaches the shell"] c["3. Wait if an old worker controls"] d["4. Activate deletes old caches"] a -->|1. Page registers sw.js| b b -->|2. Install precaches the shell| c c -->|3. Wait if an old worker controls| d
| State | Meaning | Typical action |
|---|---|---|
| Installing | The new worker is downloading and install is running | cache.addAll the precache list |
| Waiting | The new worker is ready and the old one still controls pages | Wait for tabs to close, or skipWaiting |
| Activating | activate is running | Delete obsolete caches. Optionally clients.claim |
| Activated | This worker is the one that can control clients | Fetch handlers run only while it controls the client |
| Redundant | This worker lost install or was replaced | Do not treat it as the controller |
Register, scope, and HTTPS
Workers require a secure context (HTTPS or localhost). The script URL's directory is the maximum scope. A worker at /app/sw.js cannot control / unless the response sends Service-Worker-Allowed to widen it. A scope that is too narrow is the usual "why is fetch not intercepted" bug.
updateViaCache: "none" asks the browser to check the network for sw.js updates instead of trusting the HTTP cache. The browser still checks for updates, and a byte-identical script does not install a new worker.
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
reg.addEventListener("updatefound", () => {
const worker = reg.installing;
worker?.addEventListener("statechange", () => {
console.log(worker.state);
});
});
});
}Install and activate
cache.addAll is atomic. One 404 fails the entire install, and the previous worker stays. Put only URLs you know exist in the precache list. Runtime requests belong in fetch, not in that list.
const CACHE = "app-shell-v3";
const PRECACHE = ["/", "/index.html", "/styles.css", "/app.js"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE))
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))
)
);
});Leave skipWaiting and clients.claim commented until the page shows an update toast. The runnable sketch below is the same policy without a browser.
| API | Effect | Pros | If you skip it |
|---|---|---|---|
| Default, no skipWaiting | The new worker waits until old clients are gone | No mid-session mix of generations | Users can keep a stale worker for days |
skipWaiting() | The waiting worker activates as soon as install finishes | Fast updates | Old page JS can meet new fetch rules |
clients.claim() | The new worker controls existing clients | No full restart required before intercept starts | In-flight fetches and old HTML still surprise you |
| Claim, then postMessage, then reload | The page shows "Update available" and reloads | The generation changes on purpose | You have to build the toast |
Interview line: prefer versioned caches plus a user-visible update over blind skipWaiting on a production dashboard.
Fetch and clients
Ignore non-GET so mutations are not replayed from cache. Cache only successful same-origin responses. On failure, return a precached offline page or Response.error(), and do not throw out of respondWith.
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
event.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(req);
if (cached) return cached;
try {
const fresh = await fetch(req);
if (fresh.ok && new URL(req.url).origin === self.location.origin) {
cache.put(req, fresh.clone());
}
return fresh;
} catch {
return (await cache.match("/offline.html")) || Response.error();
}
})
);
});clients.matchAll({ type: "window", includeUncontrolled: true }) finds open windows, including ones this worker does not control yet, so an update message can still reach them.
Lifecycle in memory
No navigator, no Cache Storage. The functions only encode the state machine and the allowlist.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Failure modes
| Failure | Symptom | Fix intuition |
|---|---|---|
| Worker never updates | Old behavior after deploy | Change bytes in sw.js, bypass HTTP cache for the script, call update(), or move the waiting worker |
| Precache 404 | Install fails and the new worker never activates | addAll is atomic. Fix the URL or drop it from the list |
| Caching an authenticated API | User A sees user B | Never cache Authorization or personalized JSON on a shared key |
| Scope too narrow | Some routes skip the worker | Move the script or set Service-Worker-Allowed |
| skipWaiting without reload UX | Half-updated UI | Toast, then location.reload() after claim |
| Fetch handler throws | Failed navigation | respondWith must settle to a Response |
Interview Q&A
Does a Service Worker run on the page's main thread?
Answer
No. It is a separate worker with its own event loop. The browser can stop it when idle and wake it for fetch, push, or sync.
What is the order of install, waiting, and activate?
Answer
The new worker installs. If an old worker still controls clients, the new one waits. On activate it becomes eligible to control clients. skipWaiting skips the wait.
What does clients.claim do that activate alone does not?
Answer
Activate alone does not seize pages the old worker still controls. Claim makes the new worker the controller of those clients immediately. You may still need a reload so the HTML matches the new worker.
Why did install fail after one missing icon?
Answer
cache.addAll rejects if any URL fails. The new worker becomes redundant and the old one stays.
The network tab shows no service worker on /settings. Why?
Answer
Scope. A script under /app/ does not control /settings unless the server widens scope.
When does the registering page start seeing fetch events?
Answer
Not on that first load, unless an already-active worker claims it. The next navigation is the usual moment.
Should fetch cache a POST?
Answer
No. Let the browser handle non-GET. Replaying a mutation from Cache Storage is how you double-submit.
How do open tabs learn an update exists?
Answer
updatefound and statechange on the registration, or postMessage from the worker via clients.matchAll.
Pitfalls
- Calling
skipWaiting()insideinstallon a dashboard with no toast. - Precaching
/api/me. - Forgetting
fresh.clone()beforecache.put, then returning a used body. - Debugging "the worker is killed" as a bug. Idle termination is normal. Events wake it.
A tab has been open since Monday. You shipped Wednesday. The worker script changed. Say which state the new worker is in, which cache name activate would delete, and what the user must do before the new HTML loads.