OfflineGENERALBROWSER-SPECIFICPLATFORM-SPECIFIC

The Service Worker Lifecycle

Install, activate, fetch, update — and the waiting worker that quietly leaves users running last week's code for days.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

I deployed a fix an hour ago and users are still hitting the old bug — what version of my site is actually running in their browser?

The user intent

A person opens an app they use every day. They expect it to start instantly, work when the train goes into a tunnel, and be the version the support team just told them was fixed.

The obvious build

Register a service worker on load, cache the app shell during install, and treat it like any other deployment: push new files, users get new files.

Why it breaks

The new worker downloads, installs, and then waits. As long as one tab from the old version is open, the old worker keeps controlling every request — and people leave tabs open for weeks.

How it breaks in a real browser
  • The new worker downloads, installs, and then waits. As long as one tab from the old version is open, the old worker keeps controlling every request — and people leave tabs open for weeks.
  • The user "reloads" and nothing changes, because a normal reload does not release the old worker: the new document is claimed by the worker that is still in control.
  • You add skipWaiting() to force it, and now a page that already downloaded main.a1b2.js starts requesting chunks from a deploy that no longer exists on the CDN, producing a chunk-load error mid-session (Content-Hashed Assets).
  • You test the fix, it works, you close devtools with "Update on reload" ticked — and you have been testing a code path no real user is on.
  • Registration silently does nothing on a page served over plain HTTP, so the feature "does not work" on a staging box and works everywhere else.
  • The worker registered at /app/sw.js cannot control /, so half the site is proxied and half is not, and the halves disagree about what is cached.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Register. The page calls navigator.serviceWorker.register(url, { scope }). This starts a *registration*, which outlives the page. Registration is not activation and gives no control over the current document.
  • Install. The browser fetches the worker script, evaluates it, and fires install. event.waitUntil(promise) holds the worker in installing until that promise settles — this is where a precache is filled. If the promise rejects, the install fails and the worker is discarded.
  • Wait. If an older worker still controls at least one client, the newly installed worker enters waiting. It is fully installed and doing nothing. This is a deliberate safety property: two versions of your code never control pages at the same time.
  • Activate. When the last client controlled by the old worker goes away, the new worker activates. activate is where old caches are deleted, because it is the only moment you know nothing is still reading them.
  • Control. A newly activated worker does not control existing documents unless it calls clients.claim(). Documents are claimed at navigation, which is why a page that registered a worker on first visit is usually not controlled until the next load.
  • Update. The browser re-fetches the worker script on navigation and roughly daily, and on an explicit registration.update(). If the fetched bytes differ from the installed worker's bytes, the whole cycle starts again. Byte comparison is of the *worker script*, not of the assets it caches.
  • Termination. A service worker is not a daemon. The browser starts it to handle an event and kills it when it goes idle, so module-scope variables are not durable state (IndexedDB is).
  • A service worker requires a secure context — HTTPS, or localhost as a development exemption. It also requires the script to be served from the same origin as the scope it claims.

What this makes the browser do

And which of it is avoidable.

  • Fetching and evaluating the worker script on a separate worker thread, off the document's main thread — but the *registration* and update checks are still work the browser schedules around navigation.
  • Holding an installed-but-waiting worker in memory or on disk indefinitely, which costs nothing at runtime and everything in confusion.
  • Starting the worker from cold for the first event after an idle period. That startup is on the critical path of whatever navigation triggered it — which is what navigation preload exists to overlap.
  • Byte-comparing the worker script on every update check. If your worker script is generated with a build hash inside it, every deploy is a new worker; if it is byte-identical, no deploy is ever detected.
  • Deleting caches you named in activate — a synchronous-looking caches.delete() that is really a background storage operation and can be slow on a large precache.

The states, in the order they bite you

Almost every service worker incident is a lifecycle incident, not a caching one. The cycle is short enough to hold in your head, and the only step that surprises people is the third: a fully downloaded, fully installed, correct new worker that refuses to do anything because an old tab is open.

That refusal is not a bug. It guarantees that two versions of your application logic never proxy requests at the same time. The cost of that guarantee is that your deploy cadence is now set by your users' tab-closing habits.

Service worker states
install eventclients existno old clientsblockslast client closes / skipWaiting()install rejectsPage: register()Old worker still controlling clientsScript fetched + evaluatedinstalling (waitUntil)redundantwaitingactivating (cleanup)activated + controlling
UserLLMAgentToolDataDecisionHumanGuardrail
Registration to control
  1. 1
    register()

    The page asks the browser to create or update a registration for a script and a scope. Returns immediately; control is not implied.

    fails by Insecure context, wrong MIME type, a 404 on the script, or a scope wider than the script's directory with no Service-Worker-Allowed header.

  2. 2
    install

    The worker script is evaluated and the install event fires. waitUntil() holds it here while a precache fills.

    fails by One 404 inside cache.addAll() rejects the whole promise and the worker is discarded — silently, from the page's point of view.

  3. 3
    waiting

    The worker is installed and idle because an older worker still controls at least one client. Nothing else happens until they all go.

    fails by A pinned tab. The new build waits indefinitely and no server-side signal will tell you.

  4. 4
    activate

    Fires once no old clients remain. The only safe moment to delete previous caches. clients.claim() here takes over existing documents.

    fails by Deleting a cache the app still needs, or a long waitUntil() that delays the first controlled fetch.

  5. 5
    fetch

    Every request in scope now passes through your handler, including the navigation request for the page itself (Intercepting Fetch).

    fails by A handler that throws produces a network error for a request that would otherwise have worked.

  6. 6
    update

    On navigation, on a periodic check, or on registration.update(), the browser re-fetches the script and byte-compares it. Different bytes restart the cycle.

    fails by HTTP caching on the worker script itself, which can freeze an origin on one worker version.

Read the failure column as an incident list. Every one of these has taken a real site down for returning users.

A worker that can be replaced

The two rules that matter in the code are: fill caches in install, delete caches in activate. Doing either in the other place breaks the still-running old version — the one your users are actually on.

The second thing to notice is that the version string appears in exactly one place and is used for both the cache name and the allow-list. A worker where the precache name and the cleanup list can drift apart is a worker that will eventually delete the cache it just filled.

sw.js — install and activate
1const VERSION = 'shell-v7'
2const SHELL = ['/', '/offline.html', '/app.css', '/app.js']
3
4self.addEventListener('install', (event) => {
5 // waitUntil keeps the worker in `installing` until this settles.
6 event.waitUntil(
7 caches.open(VERSION).then((cache) => cache.addAll(SHELL)),
8 )
9 // NO skipWaiting() here unless you also reload the page. See below.
10})
11
12self.addEventListener('activate', (event) => {
13 event.waitUntil((async () => {
14 // Safe *only* here: no old client is still reading these.
15 const keys = await caches.keys()
16 await Promise.all(
17 keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)),
18 )
19 // Optional: take over documents that are already open.
20 await self.clients.claim()
21 })())
22})

addAll is atomic: one 404 in SHELL rejects the promise, the install fails, and the site quietly has no offline story. Generate that list from the build, never by hand.

The update traps, and what each one costs

Every row here is a trade you are making whether or not you know you are making it. The default — no skipWaiting(), no claim() — is the conservative one: users get the new build on their next fresh session, and nothing ever swaps under a running page.

The version-skew row is the one people underestimate. A page loaded from build A has already decided which lazy chunks it will ask for. Put build B's controller in front of it and those requests can miss both the cache and the origin, so a user who was working normally gets a hard failure on their next click (Lazy Loading).

A deploy reaching one user, schematicrelative units — sequence, not duration
Deploy: new sw.js on origin
User navigates; browser re-fetches worker script
install + precache
waiting
Last old client closes
activate + cache cleanup
Next navigation is controlled by the new worker
  • User navigates; browser re-fetches worker scriptUpdate checks are tied to navigation and a browser-chosen periodic check, not to your deploy.
  • waitingThe whole lesson. Length is set by the user closing their last tab, not by anything you deployed.

Nothing on this timeline is under your control after the first row. That is the honest shape of a service worker rollout.

How updates actually go wrong
TriggerSymptomCauseResponse
A user keeps one tab open for weeksHotfix "deployed" days ago, bug still reportedThe new worker is installed and stuck in waiting; the old one still controls the clientDetect registration.waiting and offer an explicit "reload to update" action; measure how many sessions run a stale worker (Long-Lived Clients and Version Skew).
skipWaiting() on install, no reloadChunk-load errors mid-session for a subset of usersA build-A document is now controlled by a build-B worker, and build-A chunk URLs are goneReload the page as part of taking control, and keep the previous deploy's hashed assets available (Content-Hashed Assets).
Worker script served with a long max-ageNo deploy reaches returning users at allUpdate checks keep getting the same bytes from an HTTP cacheServe the worker script with revalidation. Browsers cap or bypass HTTP caching for worker scripts, but do not build on the exact cap (Browser HTTP Caching).
Worker script byte-identical across deploysAssets update, worker logic never doesThe update check compares the *worker script*, not the assets it referencesEmbed the build identifier — or the precache manifest — in the worker script so every deploy changes its bytes.
Caches cleaned up during installRequests fail for users on the old version while the new one installsThe old worker is still serving from the caches you just deletedClean up in activate only, against an explicit allow-list.
"Update on reload" left on in devtoolsEvery update path works locally and fails in the fieldThat setting bypasses waiting entirely — you never test the state your users are inTest the update path at least once with the setting off, from two open tabs.

How to build it

Most important first.

  • Decide, deliberately and in writing, whether your app updates on next session (the default: install, wait, activate when tabs close) or on user consent (a prompt that calls skipWaiting() and then reloads). Both are defensible; drifting between them is not.
  • If you use skipWaiting(), pair it with a full page reload. Swapping the controller under a running page whose already-loaded JavaScript expects the old chunk graph is the source of most "works for me" chunk errors (Deploying a Frontend).
  • Keep old asset versions on the origin for at least one deploy window. Content-hashed assets are cheap to keep and expensive to delete early (Content-Hashed Assets).
  • Serve the worker script from the highest scope you need — usually the origin root — and set its own cache headers conservatively, because a worker script the browser will not re-fetch is a worker you cannot replace.
  • Version your caches (shell-v7), and delete every cache that is not on the current allow-list inside activate. Never delete caches in install: the old worker is still serving from them.
  • Expose the version the client is actually running — in a footer, in error reports, in analytics. "Which build is this user on" must be answerable without asking them (Release Health).
  • Treat registration.update() as the deliberate way to check on a long-lived tab, rather than assuming the daily check will find your hotfix (Long-Lived Clients and Version Skew).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • An "a new version is available, reload" prompt is an interruption. It needs a real focus target, an accessible name, an escape route, and it must not steal focus from a field a person is typing in (Focus Management).
  • If the update reloads the page, focus and scroll position are lost. Restore both, or the keyboard and screen-reader user is returned to the top of a page they were halfway through (Scroll Restoration).
  • A page silently swapped to a new build gives assistive technology no signal at all. If the reload changes what is on screen, announce it in a polite live region rather than relying on the visual change (Live Regions and Announcement).
  • Never gate the update prompt behind a hover or a toast that disappears on a timer — both are unusable for people who need more time, and a timed toast is not reachable by keyboard at all.

What can go wrong

Failure modes
  • The permanently-waiting worker: a user with a pinned tab never closes it, so the new worker never activates. Weeks of deploys sit in waiting.
  • skipWaiting() without a reload: the new controller answers requests for a document rendered by the old build, and lazy chunks 404 partway through a user flow.
  • A failed install because one URL in addAll() 404s. addAll is all-or-nothing, so a single stale entry in a precache manifest silently disables the entire offline story.
  • Caches deleted in install, pulling the rug out from under the still-controlling old worker mid-request.
  • A worker script served with a long max-age from a CDN so that update checks keep returning the same bytes. The site is frozen at a version and no deploy can reach it.
  • Module-scope state (let queue = []) treated as persistent. The worker is killed between events and the queue silently resets to empty.
  • The mitigation failing: an "update available" prompt that appears on every load because updatefound also fires for the very first install, training users to dismiss it.
What can arrive out of order
  • The waiting worker versus open clients: whether a deploy reaches a user depends entirely on when they happen to close their last tab, which you do not control and cannot observe from the server.
  • Two tabs, two workers: tab A triggers an update and calls skipWaiting(); tab B, mid-form, is claimed by the new controller and starts fetching assets from a build it did not load.
  • An update check racing a navigation: the browser can be re-fetching the worker script while the new document is already being served by the old one.
  • install racing the user leaving: event.waitUntil() keeps the worker alive, but the browser can still terminate an install that outlives its budget, leaving a partially useless cache for the next visit.
Security
  • A service worker is same-origin, powerful and persistent: it sees every request in its scope, can rewrite every response, and it survives navigation, tab close and — until unregistered — future visits. It is the highest-value thing on your origin.
  • This is why registration requires a secure context. Without HTTPS, a network attacker could install a permanent worker on your origin (Why TLS Exists in Networking).
  • Scope is a containment boundary. A worker at /app/sw.js cannot claim /, and the Service-Worker-Allowed header is the only way to widen that — treat it as a deliberate grant, not a convenience.
  • A compromised or malicious worker script is worse than a compromised page script: it persists after the injection is fixed, and it can serve attacker-controlled HTML for your own URLs. Your CSP and your build-integrity story must cover the worker script specifically (Content Security Policy).
  • Anything a third-party script can register, it can register permanently. This is a concrete reason to keep third-party code out of the worker's scope (Third-Party Scripts and the Supply Chain).
Misreads
  • "Registering the worker means it is in control." The first page load that registers a worker is almost never controlled by it. Anything you test on that load is testing the network path.
  • "The user reloaded, so they have the new version." A reload replaces the document, not the controller. Only closing every client — or skipWaiting() — releases the old worker.
  • "skipWaiting() is just the fast version of the same thing." It is a different semantic: it deliberately allows a new controller to serve a page built against an old asset graph.
  • "The update check is instant." It happens on navigation and on a browser-chosen periodic schedule. A tab that never navigates may not check for a long time.
  • "Caching is the hard part of service workers." Caching is the easy part. The lifecycle is where the incidents are.

Measuring it, and what changes in the field

How you would see this
  • The Application panel's Service Workers view shows the installed, waiting and active workers, their script URLs, and gives you skipWaiting, unregister and "Update on reload" — the last of which changes the behaviour you are testing.
  • The Network panel marks responses served by the worker, which is the fastest way to see that a request never touched the network at all (Debugging the Network).
  • Ship the build identifier the worker is running with your error reports. Version skew is invisible in aggregate unless you label it (Frontend Error Tracking).
  • Count, in the field, how many sessions are controlled by a non-current worker and how old it is. That distribution is the honest answer to "has the fix rolled out" (Real User Monitoring).
Slow device, slow network, large data, old tab
  • A user with one pinned tab open for a month is a different deployment target from a user who opens the site fresh each morning. The first will sit on waiting indefinitely; the second updates on the second visit.
  • On a slow network, install can take long enough that the user has already navigated away, so the precache never completes and the next visit starts over.
  • On a device under storage pressure, the browser can evict your caches — and, if the origin's storage is cleared, the registration with them. Offline capability is not a guarantee you own.
  • In a browser's private mode, registration may be refused or scoped to the session, so an offline-first app degrades to an online-only one with no error you wrote.
What this costs
  • The waiting state is the safety property and the annoyance. Removing it with skipWaiting() buys fast rollout and pays with version skew inside a live page.
  • Prompting the user to update is honest and gives you a reload boundary, but it is one more interruption, and a meaningful fraction of users will dismiss it forever.
  • Precaching the shell at install makes the next visit instant and makes every deploy a full re-download of that shell for every user, on whatever network they happen to be on.
  • Aggressive registration.update() calls keep long-lived tabs current at the cost of a request per check, on a schedule you now own instead of the browser.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe state machine — installing, installed/waiting, activating, activated, redundant — plus the secure-context and same-origin scope rules come from the Service Workers specification and behave the same in Chromium, Gecko and WebKit.
  • BROWSER-SPECIFICThe update-check schedule, the storage eviction policy, and the devtools affordances differ: Chromium exposes waiting workers and a one-click skipWaiting in the Application panel, Firefox surfaces registrations under about:debugging, and Safari's Web Inspector offers neither in the same form, so a rollout tested only in Chromium has not been tested.
  • PLATFORM-SPECIFICOn iOS a site added to the home screen has historically run in a separate storage and worker context from the same site in the browser, so a worker installed in Safari may not be the one serving the installed app, and storage can be evicted on a shorter horizon than on desktop.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — a fleet of browsers each running whichever build they happened to install is a replicated system with no coordinator, and the waiting worker is a client-side version-skew window you cannot drain from the server.
  • Testing & Reliability Engineering — the update path is the least-tested and highest-blast-radius code path a frontend team ships; it deserves an explicit rehearsal, including a rollback.