The Service Worker Lifecycle¶
The service worker lifecycle is the sequence of states a service worker version passes through, from the moment navigator.serviceWorker.register() is called until it is replaced or unregistered: parsed → installing → installed (waiting) → activating → activated → redundant. It exists so that a new version can download and prepare everything it needs in the background while the current version keeps serving open tabs, and so that exactly one version controls a set of pages at any time. Almost every "my service worker isn't updating", "my page isn't controlled" and "my app broke after a deploy" bug is a lifecycle misunderstanding, so this page walks through each state at the level of the specification's algorithms, then shows production code for observing and steering the lifecycle from both sides.
Key takeaways
- A registration holds up to three workers: installing, waiting and active. Only the active worker receives
fetch,pushand other functional events. installruns once per version; a rejectedevent.waitUntil()promise makes the version redundant and leaves the current version untouched.register()has already resolved by then, so it cannot tell you whether installation succeeded.- A new version waits until no client uses the old version. Reloading a single tab never ends the wait because the old page is still a client while the new one loads.
self.skipWaiting()removes the wait but not the need for compatibility: open pages switch to the new version mid-session.clients.claim()takes control of uncontrolled pages, including the very first page load.- Activation cannot fail.
fetchevents from controlled pages are queued until the worker is activated, so keepactivatehandlers short. - The lifecycle state (
activated) is independent of whether the worker is running: an activated worker is stopped and restarted many times, so global variables are not state. - A forced reload (Shift + reload) bypasses the service worker entirely;
navigator.serviceWorker.controllerisnullfor that page.
The lifecycle at a glance¶
A service worker in the specification is one version of your script: a particular set of script bytes (plus imported scripts) tied to a registration. Each version moves through the ServiceWorkerState enum exactly once, in order, and can drop out to redundant at several points.
stateDiagram-v2
[*] --> parsed: script fetched and evaluated
parsed --> installing: Install algorithm starts
installing --> installed: install event settled successfully
installing --> redundant: install failed or timed out
installed --> activating: Try Activate succeeds
installed --> redundant: replaced by a newer installed version
activating --> activated: activate event settled
activated --> redundant: replaced by next version or registration cleared
redundant --> [*] ServiceWorker.state | Registration slot | Entered when | Receives events | Notes |
|---|---|---|---|---|
"parsed" | none | The script has been fetched and its top-level code evaluated | none | Initial state. The spec never transitions a worker into it, so no statechange fires for it and pages rarely observe it. |
"installing" | registration.installing | The Install algorithm starts | install, message | The worker can precache and add static routes. It controls nothing. |
"installed" | registration.waiting | All install lifetime promises fulfilled | message | Commonly called waiting. Only one worker can be in this slot. |
"activating" | registration.active | Try Activate ran Activate | activate, message | Controlled clients may already point at it; their fetch events are queued until it is activated. |
"activated" | registration.active | All activate lifetime promises settled | all functional events | The steady state. The worker is started and stopped on demand while in this state. |
"redundant" | none | Install failed, replaced, or registration cleared | none | Terminal. The worker is terminated and will never run again. |
Every transition is performed by the spec's Update Worker State algorithm, which sets the state and then queues a task in every same-origin environment (every tab and worker) that holds a ServiceWorker object for that worker, to update its state attribute and fire a statechange event. That is why all open tabs observe the same transitions, slightly after they happen.
Why the lifecycle is designed this way¶
The lifecycle looks complicated until you consider what it protects:
- Atomic upgrades. A new version can download its application shell, fonts and data into new caches while the old version continues to serve the old ones. If any download fails, the new version is discarded and nothing the user sees changes.
- One version at a time. If two versions controlled different tabs, they would share one Cache Storage and one IndexedDB while expecting different contents and schemas. The waiting phase guarantees that a new version only takes over after every page running the old code has gone away (unless you override it).
- Offline-first without surprises. The first page load is not taken over mid-flight, because it was loaded from the network with no service worker in the path; switching controllers halfway through could serve responses the page was not built to expect.
- Safe migrations. The
activateevent runs when the old version no longer controls anything (again, unless overridden), which is the moment when deleting old caches or migrating a database schema cannot break a running page.
Registration and the job queue¶
What register() validates before anything is fetched¶
navigator.serviceWorker.register(scriptURL, options) parses both URLs against the page's base URL and runs the spec's Start Register steps synchronously, so these failures reject immediately:
| Check | Result |
|---|---|
scriptURL fails to parse, is not http:/https:, or its path contains %2f or %5c (case-insensitive) | Promise rejects with TypeError |
options.scope fails to parse, is not http:/https:, or contains %2f/%5c | Promise rejects with TypeError |
No scope given | Scope defaults to "./" resolved against the script URL, i.e. the script's directory |
Then the Register job validates origins:
| Check | Result |
|---|---|
| The script URL's origin is not potentially trustworthy (not HTTPS or loopback) | SecurityError |
| The script URL is not same-origin with the registering page | SecurityError |
| The scope URL is not same-origin with the registering page | SecurityError |
Fragments are stripped from both URLs, so /sw.js#v2 identifies the same worker as /sw.js. Scope rules, Service-Worker-Allowed and multiple registrations per origin are covered in Registration & Scope.
Calling register() on every page load is cheap¶
The Register algorithm looks up an existing registration for the same storage key and scope. If one exists and its newest worker (installing, else waiting, else active) has the same script URL, the same type ("classic" or "module") and the registration has the same updateViaCache mode, the job resolves immediately with the existing registration. No network request is made and no update check happens.
That is why the standard advice is to call register() unconditionally on every page: it is idempotent. It is also why changing the script URL (for example sw.v2.js) is an anti-pattern: a page served from the old worker's cache will keep registering sw.v1.js, which is identical to its newest worker, so the new URL is never seen. Keep the worker URL stable and rely on update checks.
If any of those three values differs, the job continues into the Update algorithm, which fetches the script and, if it differs from the newest worker, installs a new version. Changing only updateViaCache does not by itself create a new version; if the bytes are identical, the Update algorithm stores the new mode and resolves.
The spec's job queue¶
Registration, update and unregistration are not performed directly. Each call creates a job (type register, update or unregister) and schedules it on a job queue keyed by the serialized scope URL (the spec's scope to job queue map). Jobs for the same scope run strictly one at a time; jobs for different scopes run independently.
flowchart LR
A["Tab A: register('/sw.js')"] --> S{"Schedule Job"}
B["Tab B: register('/sw.js')"] --> S
C["Navigation: Soft Update"] --> S
S -- "queue empty" --> RUN["Run Job now"]
S -- "equivalent to last job, still pending" --> EQ["Append to that job's list of equivalent jobs (shares its result)"]
S -- "otherwise" --> ENQ["Enqueue behind current job"]
RUN --> R1["Register or Update"]
R1 --> R2["Install"]
R2 --> FIN["Finish Job: dequeue and run next"] Details worth knowing:
- Coalescing. Two jobs are equivalent if they have the same type and, for register and update jobs, the same scope URL, script URL, worker type and
updateViaCachemode. When a new job is equivalent to the job at the back of the queue and that job's promise has not settled, it is attached to that job instead of being queued. Several tabs registering the same new worker at the same moment share one job, one network fetch and one result. - Delay until DOMContentLoaded. A note in Run Job says that for register and update jobs the user agent delays running the job until after
DOMContentLoadedhas fired in the document that initiated it. Registering from an early inline script does not make the worker download earlier than that. - Install is part of the job; activation is not. Finish Job is called at the end of the Install algorithm, right after the worker becomes
installed. Activation happens later, outside the queue, whenever Try Activate allows it. This means a second update can be fetched and installed while the previous version is still waiting. - Update jobs from the browser have no client. Update checks started by navigations or functional events (the spec's Soft Update) create a job with a null client, so no page promise is resolved; the only page-visible signal is
updatefound.
Download: fetching and checking the script¶
The Update algorithm fetches the script (and, for module workers, its static import graph) with a special request profile:
- A
Service-Worker: scriptrequest header is added to the script request, so the server can tell a worker fetch apart from a normal script load. - The request's service-workers mode is
"none": no service worker, not even the current one, can intercept it. - The cache mode is
"no-cache"(revalidate with the server) when the registration'supdateViaCacheis not"all", when the job forces a cache bypass, or when the registration is stale, meaning more than 86,400 seconds (24 hours) have passed since the last update check that reached the network. - The redirect mode is
"error": a redirected worker script fails the job. - The response must have a JavaScript MIME type (
text/javascriptand its equivalents), otherwise the job rejects with aSecurityError. - The scope must lie within the maximum scope: the script's directory by default, or the path in a
Service-Worker-Allowedresponse header. A wider scope rejects with aSecurityError.
The algorithm then decides whether this is a new version. It sets hasUpdatedResources if there is no newest worker, if the script URL or type differs, or if the main script body is not byte-for-byte identical to the stored one. For classic workers that used importScripts(), it also re-fetches every stored imported script and compares those bytes (Chrome 78+, and Firefox since version 56, per Chrome's "Fresher service workers" post); broken import responses are ignored for the comparison.
If nothing changed, the job resolves with the existing registration and ends. Nothing else happens: no install event, no updatefound.
| Failure during download or evaluation | register() / update() result | Effect on the registration |
|---|---|---|
| Network error, HTTP error status, redirect | Rejects with TypeError | A brand-new registration is removed; an existing one keeps its current workers |
| Non-JavaScript MIME type | Rejects with SecurityError | Same as above |
| Scope outside the maximum scope | Rejects with SecurityError | Same as above |
| Script throws during top-level evaluation | Rejects with TypeError | Same as above |
Module worker uses top-level await | Rejects with TypeError | Same as above |
| Script unchanged | Resolves with the registration | Nothing changes |
Parse and evaluate: the parsed state¶
When the bytes differ, the Update algorithm creates a new service worker in the parsed state and runs it (Run Service Worker), evaluating the top-level script before any event is dispatched. Three things happen during this first evaluation that shape the worker's entire life:
- Errors abort the update. An exception thrown by top-level code makes Run Service Worker return an abrupt completion. The job's promise rejects with a
TypeErrorand the new version is discarded. For a first registration, the registration itself is removed. - Event types are recorded. After the very first evaluation of a script version, the spec copies the event types of all listeners currently registered on the global into the worker's set of event types to handle. On later starts the set is not updated. The Should Skip Event algorithm permits the browser to skip dispatching any event type not in that set, so a listener added asynchronously may be ignored forever.
- Empty fetch listeners are detected. The browser may set an all fetch listeners are empty flag when every
fetchlistener has an empty function body. Chromium warns about such handlers in the console since Chrome 112 and, since Chrome 115, takes worker start-up andfetchdispatch off the critical path of navigations (ChromeStatus); the spec still lets the browser start the worker in parallel and run an update check.
The same top-level code runs again every time the worker is started for an event after being stopped, possibly hundreds of times per version. Keep it free of side effects: no network requests, no writes, nothing that assumes it runs once.
// Constants and pure setup only.
const VERSION = "2026-09-25.1";
const PRECACHE = `precache-${VERSION}`;
const RUNTIME = "runtime";
// Every listener registered synchronously, during the first evaluation.
self.addEventListener("install", onInstall);
self.addEventListener("activate", onActivate);
self.addEventListener("fetch", onFetch);
self.addEventListener("message", onMessage);
// WRONG: this listener is added after the first evaluation finishes, so the
// browser may never dispatch "push" to it.
// setTimeout(() => self.addEventListener("push", onPush), 0);
Install: preparing the new version¶
What happens when installation starts¶
The Install algorithm runs these steps in order (paraphrased from the specification):
- Record the current newest worker (so a failure can be rolled back) and store the job's
updateViaCachemode on the registration. - Put the new worker in
registration.installingand set its state toinstalling. EveryServiceWorkerRegistrationobject for this registration, in every tab, gets itsinstallingattribute updated by a queued task. - Resolve the job promise. The
register()orupdate()promise resolves with the registration here, before any install code has run. - Queue a task, in every same-origin environment, to fire
updatefoundon eachServiceWorkerRegistrationobject for the registration. - If the worker registered an
installlistener, start it and dispatch anInstallEvent, then wait until the event is no longer active (allwaitUntil()promises settled, or the browser timed it out). - On failure, go to the failure path described below.
- Prune imported scripts the new version did not actually use from its script resource map.
- If a worker is already waiting, terminate it and mark it
redundant. - Move the new worker to
registration.waiting, clearregistration.installing, and set its state toinstalled. - Finish the job so the next queued job can run, then invoke Try Activate.
Because step 3 happens before step 5, a resolved register() promise does not mean the worker installed. It means the script downloaded, evaluated and entered the installing state. To know whether installation succeeded, observe statechange on registration.installing (code below).
waitUntil() in install and what failure means¶
installFailed is set if any of these happen:
- A promise passed to
event.waitUntil()rejects. - The browser sets the event's timed out flag. Chromium kills a worker whose event exceeds 5 minutes (
kRequestTimeoutinservice_worker_version.h); WebKit fails the installation if the worker thread does not respond to a 60-second heartbeat; Firefox lets pending promises keep a worker alive only for its idle timeout plus an extended timeout (30 seconds each by default) before terminating it. - The worker cannot be started, or the task that would dispatch the event is discarded (for example because the worker was terminated).
On failure the worker's state becomes redundant, registration.installing becomes null, and if there was no previous version, the registration is removed entirely. If there was a previous version, it keeps working as if nothing happened; the next update check will try again.
A synchronous exception thrown inside the install listener is reported to the console but is not, by itself, one of the spec's failure conditions. Do not rely on throwing to abort an installation; express failure through the promise you pass to waitUntil().
Precache lists are all-or-nothing
cache.addAll() rejects if any single response is not OK, which fails the whole installation. That is the point: a version with an incomplete shell must not activate. But it also means one flaky URL (a third-party font, an image that 404s) blocks every future update. Precache only what the app cannot run without, and cache everything else at runtime.
What belongs in the install handler¶
Use install to make the new version self-sufficient before it can ever control a page:
- Precache the versioned application shell into a cache whose name includes the version, so the old version's cache is untouched.
- Add static routes with
event.addRoutes()where supported; routes can only be added duringinstall. - Optionally call
self.skipWaiting()if, and only if, this version is safe to run alongside pages loaded by the previous one (see skipWaiting() semantics and risks).
Do not delete old caches, migrate shared databases or call clients.claim() in install: the old version is still in charge and may be serving pages from those caches, and claim() rejects with InvalidStateError because the worker is not active yet.
const VERSION = "2026-09-25.1";
const PRECACHE = `precache-${VERSION}`;
// Keep this list short and versioned by your build (hashed filenames).
const PRECACHE_URLS = [
"/",
"/offline.html",
"/assets/app.3f9c2e1b.js",
"/assets/app.8d7a41c0.css",
"/assets/logo.svg",
];
self.addEventListener("install", (event) => {
event.waitUntil(precache());
// Static routes: skip the worker entirely for API traffic where supported.
if ("addRoutes" in event) {
event.addRoutes({
condition: { urlPattern: "/api/*" },
source: "network",
}).catch((error) => {
// addRoutes() failures do not fail installation (the spec always
// fulfills its lifetime promise), but log them.
console.warn("[sw] addRoutes failed", error);
});
}
});
async function precache() {
const cache = await caches.open(PRECACHE);
// cache: "reload" bypasses the HTTP cache so a stale copy of the shell
// is never stored under a new version's name.
const requests = PRECACHE_URLS.map((url) => new Request(url, { cache: "reload" }));
try {
await cache.addAll(requests);
} catch (error) {
// Clean up the partial cache, then rethrow so installation fails and the
// current version keeps serving. The next update check retries.
await caches.delete(PRECACHE);
throw error;
}
}
Installed and waiting¶
Why the waiting phase exists¶
After a successful install, the new version sits in registration.waiting with state installed. It will not receive fetch or any functional event, and it will not control any page, until Try Activate lets it through. For an update, Try Activate only activates it if the currently active worker has no pending extended events and either no service worker client is using the registration or the waiting worker's skip waiting flag is set.
A client is using a registration when its active service worker belongs to that registration. That includes every controlled window (tabs, installed PWA windows, iframes) and every controlled dedicated or shared worker. It does not include pages that are open but uncontrolled, such as a page loaded with a forced reload.
In practice the wait ends when:
- the user closes or navigates away every tab and window controlled by the old version (the spec's Handle Service Worker Client Unload runs Try Activate when the last one unloads);
- the waiting worker calls
self.skipWaiting(); - the browser shuts down (per the spec, a waiting worker is promoted to active on shutdown; see browser restarts);
- you click skipWaiting in DevTools.
Why a refresh does not activate the waiting worker¶
The most common lifecycle surprise: you deploy a new sw.js, reload the only open tab, and the old version is still in charge. Reloading again does not help either.
sequenceDiagram
participant Old as Old page (controlled by v1)
participant B as Browser
participant V1 as SW v1 (active)
participant V2 as SW v2 (waiting)
participant New as New page
Note over V2: installed, waiting for v1's clients to go away
Old->>B: User presses reload
B->>V1: fetch event for the navigation (v1 is still the active worker)
Note over B,New: The new page's reserved client now uses the registration
V1-->>B: Response
B->>Old: Unload old document
Note over B: Handle Client Unload - but the new page is already a client
B->>New: Commit new document, still controlled by v1
Note over V2: Still waiting The navigation request for the reloaded page is dispatched to the active worker (v1), and in doing so the new page's reserved client is assigned v1 as its active service worker. When the old document unloads, another client (the new document) is still using the registration, so Try Activate does nothing. There is never a moment with zero clients. Jake Archibald's lifecycle article on web.dev summarizes it as: "the current service worker is always controlling a client during a refresh."
To get v2 in charge you must close every controlled window, navigate every one of them to a page outside the scope, or trigger skipWaiting().
A newer update replaces the waiting worker¶
Only one worker can wait. If you deploy v3 while v2 is still waiting, the Install algorithm installs v3 and, at step 8, terminates v2 and marks it redundant before v3 takes the waiting slot. Pages that tracked registration.waiting must therefore handle the waiting worker changing underneath them; the observer code later on this page attaches its listeners to every new installing worker from updatefound, so it follows v3 as well.
Activating and activated¶
When activation is allowed: Try Activate¶
flowchart TD
A["Try Activate"] --> B{"Is there a waiting worker?"}
B -- No --> Z["Do nothing"]
B -- Yes --> C{"Is the active worker still activating?"}
C -- Yes --> Z
C -- No --> D{"Is there an active worker at all?"}
D -- No --> ACT["Activate"]
D -- Yes --> E{"Does the active worker have pending extended events?"}
E -- Yes --> Z
E -- No --> F{"Is any client using the registration?"}
F -- No --> ACT
F -- Yes --> G{"Is the waiting worker's skip waiting flag set?"}
G -- Yes --> ACT
G -- No --> Z Try Activate is invoked at the end of every installation, when skipWaiting() is called, when the last client using the registration unloads, and when the pending promise count of any extended event in the worker drops to zero. Note the pending-events check: even with skipWaiting(), the new version waits for the old version's in-flight events to settle. A long respondWith() stream (a large download, a streamed video) or a slow waitUntil() in the old worker postpones activation until it finishes or the browser times it out.
What happens during Activate¶
The Activate algorithm is short and its order explains several observable behaviors:
- If there is an active worker, terminate it and set its state to
redundant. - Move the waiting worker to
registration.active, clearregistration.waiting, and set the new active worker's state toactivating. From this point, the spec notes, "neither a runtime script error nor a force termination of the active worker prevents the active worker from getting activated." - For every client whose URL matches the scope, resolve its pending
navigator.serviceWorker.readypromise with the registration. - For every client already using the registration (pages controlled by the old version), set its active service worker to the new worker and fire
controllerchangeon itsnavigator.serviceWorker. - If the worker listens for
activate, start it and dispatch anExtendableEventnamedactivate, then wait until the event is no longer active. - Set the state to
activated.
Consequences:
- Pages observe the old worker's
statechangetoredundantbefore the new worker'sstatechangetoactivating. navigator.serviceWorker.readyresolves when the worker isactivating, notactivated. The registration's active worker exists, but itsactivatehandler may still be running.- On a first install no client is using the registration yet, so step 4 does nothing: the page that registered the worker stays uncontrolled and receives no
controllerchange(unless the worker callsclients.claim()). - On an update with
skipWaiting(), open pages switch controllers at step 4, before youractivatehandler has run.
Fetch and functional events wait for activated¶
The Handle Fetch algorithm contains the line: "If activeWorker's state is activating, wait for activeWorker's state to become activated." Fire Functional Event has the same wait for push, sync and every other functional event. Everything a controlled page requests while your activate handler runs is queued behind it.
That is the purpose of waitUntil() in activate: the spec describes it as ensuring "that any functional events are not dispatched to the service worker until it upgrades database schemas and deletes the outdated cache entries." It is also the reason to keep the handler fast. A multi-second migration in activate shows up as multi-second stalls on every request from pages that were switched over by skipWaiting() or claim().
Activation cannot fail¶
Unlike install, the Activate algorithm has no failure path. If a waitUntil() promise rejects, or the handler throws, or the worker is killed mid-activation, the worker still becomes activated. The specification explicitly advises making "activation handlers ... do non-essential work (like cleanup)" because "activation handlers may not all run to completion, especially in the case of browser termination during activation." WebKit's implementation carries a comment to the same effect: a worker "goes to activated even if activating fails."
Design activate so that it is idempotent and so that the worker still works if it never ran to completion: delete caches by comparing names against an allow-list, and make database migrations resumable.
What belongs in the activate handler¶
const VERSION = "2026-09-25.1";
const PRECACHE = `precache-${VERSION}`;
const RUNTIME = "runtime";
const OWNED_PREFIXES = ["precache-", "runtime"];
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// 1. Enable navigation preload first: it only needs an active worker
// and speeds up every navigation from now on.
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
// 2. Delete only caches this worker owns and no longer needs. Other
// libraries on the origin may keep their own caches.
const keep = new Set([PRECACHE, RUNTIME]);
const names = await caches.keys();
await Promise.all(
names
.filter((name) => OWNED_PREFIXES.some((p) => name.startsWith(p)))
.filter((name) => !keep.has(name))
.map((name) => caches.delete(name))
);
// 3. Take control of uncontrolled clients only if this app is built
// for it (see "Why the first page load is not controlled").
// await self.clients.claim();
})()
);
});
Database migrations deserve special care. IndexedDB version upgrades are blocked while another connection to the database with an older version is open, and pages running the previous release may hold exactly such a connection. Handle versionchange in every page (close the connection and prompt a reload) and blocked in the worker; the IndexedDB page shows the full pattern.
Redundant: how a worker leaves the lifecycle¶
redundant is terminal. The spec reaches it through these paths:
| Path | Where in the spec | What the page sees |
|---|---|---|
Installation failed (rejected waitUntil(), timeout, worker failed to start) | Install, failure branch | registration.installing becomes null; the worker's statechange to redundant |
| A newer version finished installing while this one was waiting | Install, step 8 | registration.waiting switches to the newer worker |
| A newer version was activated | Activate, step 1 | The old worker's statechange to redundant, then the new one's to activating |
| The registration was unregistered and its last client went away | Clear Registration | All three slots become null |
| The installing worker was discarded at browser shutdown | Handle User Agent Shutdown | The version is simply gone after restart |
Any ServiceWorker object whose state is redundant is inert: postMessage() to it is silently dropped because the worker can never be run again.
First install versus update¶
The same algorithms run for a first install and for an update, but the starting conditions differ, and so does what the user experiences.
| First install | Update | |
|---|---|---|
| Trigger | register() on a page with no existing registration for the scope | A navigation into scope, a functional event on a stale registration, registration.update(), or register() with a different script URL |
| Newest worker before the job | none | the current active (or waiting) worker |
| If install fails | The registration is removed; the next register() starts from scratch | The previous version keeps running; the failed version becomes redundant |
| Waiting phase | Effectively skipped: Try Activate sees no active worker and activates immediately | Lasts until no client uses the old version, unless skipWaiting() is called |
controllerchange in open pages | None, unless the worker calls clients.claim() | Fires in controlled pages when the new worker activates while they are open (only possible with skipWaiting()) |
| Is the registering page controlled? | No, until it is reloaded or claimed | Already controlled by the old version; switches only with skipWaiting() |
navigator.serviceWorker.ready | Resolves when the worker reaches activating | Already resolved |
sequenceDiagram
participant Page
participant C as navigator.serviceWorker
participant R as Registration
participant SW as SW v1
Page->>C: register("/sw.js")
C->>R: Schedule register job (after DOMContentLoaded)
R->>SW: Fetch script, evaluate (parsed)
R->>SW: state = installing
C-->>Page: register() resolves, installing = v1
R-->>Page: updatefound
R->>SW: dispatch install
SW-->>R: waitUntil promises fulfilled
R->>SW: state = installed (waiting, briefly)
Note over R: Try Activate: no active worker, activate now
R->>SW: state = activating
R-->>Page: ready resolves
R->>SW: dispatch activate
SW-->>R: waitUntil promises settled
R->>SW: state = activated
Note over Page: Page is still NOT controlled (controller is null) sequenceDiagram
participant Tab as Open tab (controlled by v1)
participant R as Registration
participant V1 as SW v1 (active)
participant V2 as SW v2
Tab->>V1: Navigation handled by v1
R->>V2: Soft update: script bytes differ, evaluate v2
R->>V2: state = installing
R-->>Tab: updatefound
R->>V2: dispatch install
R->>V2: state = installed (waiting)
Note over R: Try Activate: v1 has clients, no skip waiting flag
Tab->>Tab: User closes the last controlled tab
Note over R: Handle Client Unload, then Try Activate
R->>V1: terminate, state = redundant
R->>V2: state = activating, dispatch activate
R->>V2: state = activated
Note over V2: Next navigation is controlled by v2 One subtle consequence of the first-install flow: registration.waiting is non-null for a moment between steps 9 and Activate, and a page can observe statechange to installed even on a first install. Code that shows an "Update available" banner whenever a worker reaches installed will show it on the very first visit. Guard update UI on navigator.serviceWorker.controller being non-null, as the observer module below does.
Why the first page load is not controlled¶
Which service worker controls a document is decided when the document is created, not later. For a navigation, the Handle Fetch algorithm matches the request URL against the registrations and, if a registration with an active worker is found, sets the new document's reserved client's active service worker. On the first visit there is no registration yet, so the document is created uncontrolled and stays that way: the worker you register from that page installs and activates, but the page's requests keep going straight to the network. The spec's non-normative section on window clients puts it simply: "If the fetch is routed through HTTP fetch, the window client's active service worker is set to the result of the service worker registration matching."
This is deliberate. The page was built from network responses without any service worker logic in the path; switching it to a worker mid-session could mix cached and network resources in ways the page never expected. For most sites the right response is to do nothing: the second navigation is controlled, and that is when offline support starts to matter.
clients.claim() in detail¶
If your app needs the worker to control the current page immediately (for example, because the page itself relies on runtime caching or because you want analytics requests queued offline from the first visit), call clients.claim() from the active worker:
The claim() algorithm:
- Rejects with
InvalidStateErrorif the worker is not the registration's active worker. It works duringactivating(inside theactivatehandler), but not duringinstall. - Iterates over every client with the same storage key that is execution-ready, not discarded, and in a secure context.
- Skips any client whose URL matches a different registration (longest-scope matching), or that matches no registration (for example because this registration was unregistered).
- For every remaining client not already controlled by this worker: runs Handle Service Worker Client Unload for its previous registration (which can let that registration's waiting worker activate), sets its active service worker to this worker, and fires
controllerchangeon it. - Resolves once all clients were processed.
claim() affects uncontrolled clients (first visit, force-reloaded pages) and clients currently controlled by another registration of the same origin whose scope is a less specific match for their URL than this registration's scope. It does not affect which version controls pages already controlled by this registration: those are switched only by activation.
Risks to weigh before using it:
- Mixed provenance. Resources the page loaded before the claim came from the network; later lazy-loaded resources come through the worker. If the worker serves a cached version of a code-split chunk from a different build than the page's entry bundle, the app breaks.
- Unexpected
controllerchange. Pages that reload oncontrollerchange(a common update pattern) will reload on the very first visit if the worker claims them. Guard reload logic as shown below. - Workers are claimed too. Dedicated and shared workers created by the page are clients as well and get claimed if their URL is in scope.
skipWaiting(): semantics and risks¶
self.skipWaiting() lets a newly installed version activate even while pages controlled by the previous version are open.
What skipWaiting() does and does not do¶
The spec's algorithm is three steps, run in parallel with the caller: set the worker's skip waiting flag, invoke Try Activate, and queue a task to resolve the returned promise with undefined.
- It can be called at any time, from any state. Calling it in
installis the most common placement; the flag is set immediately and takes effect when installation completes and Try Activate runs. Calling it in the waiting state (for example from amessagehandler) activates the worker right away, as soon as the old worker has no pending events. - The promise tells you nothing about activation. It resolves after the flag is set, typically before activation begins. There is no need to
awaitit, and awaiting it insideinstalldoes not delay anything meaningfully. - It does not bypass installation. A worker whose install fails never activates, flag or not.
- It does not interrupt the old worker's in-flight work. Try Activate still requires the active worker to have no pending extended events. Long-lived streaming responses in the old version delay activation.
- It switches controllers for open pages. During Activate, every client using the registration gets the new worker as its controller and a
controllerchangeevent, before youractivatehandler has finished. - It does not reload pages. Open pages keep running the old HTML and JavaScript while their requests are now handled by the new worker.
The risks of running a new worker under old pages¶
With skipWaiting(), a page loaded with release N is served by release N+1's service worker. That is safe only if release N+1 can answer every request release N might still make:
- Lazy-loaded chunks. Release N's router requests
/assets/settings.a1b2c3.js. Release N+1'sactivatehandler deleted theprecache-Ncache, and the hashed file no longer exists on the server either. The request fails and the page shows a chunk-load error. - API and message contracts. The page posts a message the new worker no longer understands, or the worker rewrites API responses into a shape the old page cannot parse.
- Storage schemas. The new worker upgrades an IndexedDB schema while the old page still holds a connection at the previous version.
Mitigations: keep the previous release's precache for one generation instead of deleting it immediately, keep old hashed assets deployed on the server for a while, version your message protocol, and make database migrations backward compatible.
Safe patterns for skipWaiting()¶
// The page shows "A new version is available - Reload" and, when the
// user accepts, asks the waiting worker to activate. That page reloads
// on controllerchange; other open tabs of the old release are switched
// too, so show them a softer "refresh to update" hint (see below).
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
The user-prompted pattern is the one most production PWAs use, and the one Workbox supports out of the box with workbox-window. The trade-offs between these strategies, including forced reloads on navigation and "reload on next visit," are compared on Updating Service Workers.
controllerchange and reloading pages safely¶
controllerchange fires on navigator.serviceWorker whenever the page's controller changes: during Activate (only possible for an already-controlled page when the new worker skipped waiting) and during clients.claim(). It never fires on a normal page load, and it does not fire when a waiting worker appears.
A robust reload handler needs three guards: reload only once, do not reload a page that had no controller before (first install plus claim()), and preferably reload only when the user asked for the update.
// Captured at page load: was this page controlled when it started?
const hadControllerAtLoad = Boolean(navigator.serviceWorker?.controller);
let userRequestedUpdate = false;
let reloading = false;
export function markUpdateRequested() {
userRequestedUpdate = true;
}
navigator.serviceWorker?.addEventListener("controllerchange", () => {
// Guard 1: the first install claimed this page; there is nothing stale to replace.
if (!hadControllerAtLoad) return;
// Guard 2: some other tab triggered skipWaiting(); let this tab keep running
// and show a softer "refresh to update" hint instead of reloading under the user.
if (!userRequestedUpdate) {
document.dispatchEvent(new CustomEvent("sw:controller-replaced"));
return;
}
// Guard 3: never loop. Some browsers can fire controllerchange more than once.
if (reloading) return;
reloading = true;
window.location.reload();
});
Observing the lifecycle from the page¶
updatefound, statechange and the registration slots¶
The page-side API surface is small and spread over three objects:
| Object | Members | Use |
|---|---|---|
ServiceWorkerContainer (navigator.serviceWorker) | controller, ready, register(), getRegistration(), getRegistrations(), startMessages(), events controllerchange, message, messageerror | Is this page controlled, and by which worker? Wait for an active worker. |
ServiceWorkerRegistration | installing, waiting, active, scope, updateViaCache, navigationPreload, update(), unregister(), event updatefound | Which versions exist right now; when a new one starts installing. |
ServiceWorker | state, scriptURL, postMessage(), events statechange, error | Follow one version through its states; send it messages. |
Three rules make observer code correct:
updatefoundonly tells you a new installing worker exists. Readregistration.installinginside the handler and attach astatechangelistener to that object.- You may have missed events. A page loaded while a worker is already waiting will never see
updatefoundfor it. Checkregistration.waiting(andregistration.installing) right after obtaining the registration. - Within one page, each worker is represented by exactly one
ServiceWorkerobject. The spec's service worker object map guaranteesregistration.waiting === registration.activestyle comparisons work for identity checks, so you can tell whether the worker inwaitingis the one you were tracking.
A complete lifecycle observer module¶
The module below registers the worker, reports every lifecycle transition, distinguishes "installed for the first time" from "update ready," surfaces installation failures, and exposes an applyUpdate() function for an update prompt. It has no dependencies.
/**
* Registers a service worker and reports lifecycle transitions.
*
* Callbacks:
* onState(worker, state) every statechange of any tracked worker
* onFirstInstall(registration) first version activated (page controlled only after reload or claim())
* onUpdateReady(worker) a new version is waiting and a controller exists
* onInstallFailed(worker) an installing worker became redundant
*/
export async function registerWithLifecycle(
scriptURL,
{
scope = "/",
type = "classic",
updateViaCache = "none",
onState = () => {},
onFirstInstall = () => {},
onUpdateReady = () => {},
onInstallFailed = () => {},
} = {}
) {
if (!("serviceWorker" in navigator)) {
return { supported: false };
}
const container = navigator.serviceWorker;
const tracked = new WeakSet();
let registration;
let isFirstInstall = false;
function track(worker) {
if (!worker || tracked.has(worker)) return;
tracked.add(worker);
let wasInstalling = worker.state === "installing";
worker.addEventListener("statechange", () => {
onState(worker, worker.state);
if (worker.state === "installed") {
wasInstalling = false;
// A worker can reach "installed" on a first install too; only an
// existing controller means there is an old version to replace.
if (container.controller) onUpdateReady(worker);
}
// Only the very first version of a registration counts as a first
// install; a force-reloaded page also has no controller during updates.
if (worker.state === "activated" && isFirstInstall) {
isFirstInstall = false;
onFirstInstall(registration);
}
if (worker.state === "redundant" && wasInstalling) {
onInstallFailed(worker);
}
});
}
try {
registration = await container.register(scriptURL, { scope, type, updateViaCache });
} catch (error) {
// TypeError: download, HTTP status, redirect or evaluation failure.
// SecurityError: insecure origin, bad MIME type or scope too wide.
console.error("[sw] registration failed", error);
return { supported: true, error };
}
// No active worker yet means this registration is being installed for the first time.
isFirstInstall = !registration.active;
// Catch up on state that existed before this page loaded.
if (registration.waiting && container.controller) onUpdateReady(registration.waiting);
track(registration.installing);
track(registration.waiting);
track(registration.active);
registration.addEventListener("updatefound", () => {
// The newly installing worker; attach listeners before it progresses.
track(registration.installing);
});
function applyUpdate() {
const waiting = registration.waiting;
if (!waiting) return false;
waiting.postMessage({ type: "SKIP_WAITING" });
return true;
}
return { supported: true, registration, applyUpdate };
}
import { registerWithLifecycle } from "./sw-lifecycle.js";
import { markUpdateRequested } from "./reload-on-update.js";
window.addEventListener("load", async () => {
const result = await registerWithLifecycle("/sw.js", {
onState: (worker, state) =>
console.debug(`[sw] ${new URL(worker.scriptURL).pathname} -> ${state}`),
onFirstInstall: () => showToast("This app now works offline."),
onUpdateReady: () => showUpdateBanner(),
onInstallFailed: () => console.warn("[sw] new version failed to install; keeping the current one"),
});
if (!result.registration) return;
document.querySelector("#update-button")?.addEventListener("click", () => {
markUpdateRequested();
if (!result.applyUpdate()) window.location.reload();
});
// Long-lived tabs and installed windows rarely navigate; check for
// updates when the app returns to the foreground.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
result.registration.update().catch(() => {
// Offline or the script is temporarily unavailable: ignore.
});
}
});
});
function showToast(message) {
/* app-specific UI */
}
function showUpdateBanner() {
/* app-specific UI; the button calls applyUpdate() */
}
registration.update() returns a promise that resolves with the registration once the update check has an outcome: either the script was unchanged, or a new version has started installing (the same early resolution as register()). It rejects on download or evaluation errors. It rejects with InvalidStateError when called from a worker that is itself installing, or when the registration has no worker at all.
Observing the lifecycle inside the worker¶
The worker can inspect its own position in the lifecycle through self.registration and, where supported, self.serviceWorker:
const channel = new BroadcastChannel("sw-lifecycle");
function report(event, extra = {}) {
const payload = {
event,
// self.serviceWorker: Chrome/Edge 79+, Safari 15.4+; undefined in Firefox.
state: self.serviceWorker?.state ?? "unknown",
script: self.location.pathname,
installing: Boolean(self.registration.installing),
waiting: Boolean(self.registration.waiting),
active: Boolean(self.registration.active),
time: Math.round(performance.now()),
...extra,
};
console.debug("[sw]", payload);
channel.postMessage(payload);
}
// Top-level code runs on every start, so this line is logged each time the
// browser boots the worker, which makes idle termination visible.
report("script-evaluated");
self.addEventListener("install", (event) => {
report("install-start");
event.waitUntil(
precache().then(
() => report("install-done"),
(error) => {
report("install-failed", { message: String(error) });
throw error; // keep the rejection so installation fails
}
)
);
});
self.addEventListener("activate", (event) => {
report("activate-start");
event.waitUntil(cleanUp().finally(() => report("activate-done")));
});
self.registration.addEventListener("updatefound", () => {
// Fires when a version of this registration starts installing, provided
// this worker happens to be running at that moment.
report("newer-version-installing");
});
new BroadcastChannel("sw-lifecycle").onmessage = (event) => console.table([event.data]);
Running state versus lifecycle state: termination while idle¶
Two independent state machines are easy to conflate:
- Lifecycle state (
ServiceWorker.state):installing→installed→activating→activated→redundant. Changes a handful of times per version, persists across browser restarts. - Running state: whether the worker's thread and global scope currently exist. Chrome DevTools shows it as running or stopped. It flips constantly: the browser starts an activated worker for a
fetch,pushormessageevent and stops it when it has been idle, about 30 seconds in Chromium and Firefox by default. WebKit keeps it running while pages of the origin are open. See Service worker lifetime.
Each time an activated worker is started, its script is evaluated again from the stored bytes, in a fresh global scope. It does not receive install or activate again. That has direct consequences for lifecycle code:
- Work done in
installoractivatemust be persisted (caches, IndexedDB). Anything assigned to a global variable in those handlers is gone the next time the worker starts, typically within a minute. - Do not "initialize once" in
activateand assume it holds: open database connections, parsed configuration and route tables must be lazily re-created on demand. - Do not expect a
statechangeoractivatewhen the worker is restarted. From the page's point of view nothing happens;navigator.serviceWorker.controlleris the same object throughout.
let routesPromise; // memoized only for the lifetime of this worker instance
function getRoutes() {
// Re-created lazily after every restart instead of once in "activate".
routesPromise ??= (async () => {
const cache = await caches.open("config");
const response = await cache.match("/sw-routes.json");
// Built-in defaults if the config was never cached.
return response ? response.json() : { networkOnly: ["/api/"] };
})().catch((error) => {
routesPromise = undefined; // retry on the next event rather than memoizing a failure
throw error;
});
return routesPromise;
}
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return;
event.respondWith(
(async () => {
const { networkOnly } = await getRoutes();
if (networkOnly.some((prefix) => url.pathname.startsWith(prefix))) {
return fetch(event.request);
}
const cached = await caches.match(event.request);
return cached ?? fetch(event.request);
})()
);
});
ExtendableEvent rules during install and activate¶
Both lifecycle events are ExtendableEvents (install is an InstallEvent in Chromium and in Safari 27+, which adds addRoutes()). The general rules are on the section overview; the lifecycle-specific ones are:
| Rule | install | activate |
|---|---|---|
Rejected waitUntil() promise | Installation fails; worker becomes redundant | Ignored; the worker becomes activated anyway |
| Listener throws synchronously | Reported; not a spec failure condition on its own | Reported; activation proceeds |
| Browser time limit exceeded | Timed out flag set, installation fails | Activation proceeds; handlers may be cut short |
| Other events during the handler | message can arrive; no functional events | fetch and functional events are queued until activated |
clients.claim() | Rejects with InvalidStateError | Allowed |
self.skipWaiting() | Allowed; takes effect after installation | Allowed but pointless; the worker is already active |
event.waitUntil() after an await | Throws InvalidStateError unless an earlier promise is still pending | Same |
Because waitUntil() must be called while the event is active, the idiomatic structure is to pass a single async function's promise synchronously, and never to await anything before calling it:
// Correct
self.addEventListener("install", (event) => {
event.waitUntil(doInstall());
});
// WRONG: by the time waitUntil runs, the dispatch has finished and no
// lifetime promise is pending, so it throws InvalidStateError and the
// worker is installed without its caches.
self.addEventListener("install", async (event) => {
const cache = await caches.open("precache-v1");
event.waitUntil(cache.addAll(["/"]));
});
Hard reload and other ways to bypass the service worker¶
The specification builds in an escape hatch for users and developers. In Handle Fetch: "If request is a navigation request and the navigation triggering it was initiated with a shift+reload or equivalent, return null." A null return means the fetch proceeds without any service worker, and because no active worker is assigned to the new document, navigator.serviceWorker.controller is null for that page. The spec repeats it in a note on controller: it "returns null if the request is a force refresh (shift+refresh)."
What that means in practice:
- Ctrl+Shift+R / Cmd+Shift+R (or Shift + clicking reload) loads the page and all of its subresources without the worker. It does not unregister or update anything.
navigator.serviceWorker.readystill resolves (the registration has an active worker), so code that assumes "ready means controlled" is wrong on force-reloaded pages. Checknavigator.serviceWorker.controllerinstead.- The page remains uncontrolled until its next normal navigation, or until a worker calls
clients.claim(). - A force-reloaded page does not count as a client using the registration, so it does not hold back a waiting worker.
Other ways a request or page ends up outside the worker:
| Situation | Result |
|---|---|
| DevTools Bypass for network checkbox (Chrome) | Requests go to the network; the page may still report a controller |
Request destination embed or object | Never dispatched to the worker |
Document with an opaque origin (sandboxed iframe without allow-same-origin, data: URL) | Never controlled |
| Navigation outside the scope | Not controlled by this registration |
Request matched by a static route with source "network" or "cache" | Handled by the browser without starting the worker |
When update checks happen¶
A new version can only enter the lifecycle if the browser checks for one. Per the specification, an update check (Soft Update) runs:
- after a navigation into the scope is handled (Chromium schedules it about one second later, per
kUpdateDelayinservice_worker_context.h); - after a functional event (
push,sync,notificationclickand others) or a subresource request, but only if the registration is stale (no network update check for more than 24 hours); - when you call
registration.update(); - when you call
register()with a script URL, type orupdateViaCachevalue different from the newest worker's.
The checks go through the job queue described above. Everything about controlling the HTTP cache for sw.js, rate limits and update UX is on Updating Service Workers.
Unregistering: how a registration ends¶
registration.unregister() schedules an unregister job. The Unregister algorithm removes the registration from the browser's registration map immediately and resolves the promise with true (or false if there was nothing to unregister). The workers are not stopped yet: the spec's note says "the currently controlled service worker client's active service worker's containing service worker registration is effective until all the service worker clients (including itself) using this service worker registration unload. That is, the unregister() method only affects subsequent navigations."
Try Clear Registration then waits until no client uses the registration and no worker has pending events, and runs Clear Registration, which terminates the installing, waiting and active workers and sets each to redundant. Until then, open pages keep their controller.
Two consequences:
- After
unregister(), callingregister()again creates a new registration and a first install, even while old pages are still controlled by the old one. - To remove a broken worker from users' devices, you cannot rely on
unregister()from a page the broken worker may never let load. Deploy a replacementsw.jswith nofetchhandler that callsself.registration.unregister()inactivate(a "kill switch"). The pattern and its caveats are on Pitfalls & Anti-Patterns.
Browser restarts and the lifecycle¶
Registrations persist across restarts; running workers and in-progress installations do not. The spec's Handle User Agent Shutdown algorithm defines what survives:
- An installing worker is discarded. If it was the only worker, the registration is discarded too. The next
register()or update check starts over. - A waiting worker is promoted to active. The spec invokes Activate for every registration with a waiting worker at shutdown. Even where an implementation defers that step, a restarted browser has no open clients holding the old version, so nothing stops the waiting version from activating on first use. This is why "the update appeared after I restarted the browser" is expected behavior, not a fluke.
Installed PWAs behave the same way as tabs: an app window is a client, so closing every app window ends the waiting phase just like closing the last tab.
Browser support¶
Support data as of September 2026. See MDN's ServiceWorker compatibility data and caniuse for live data.
| Lifecycle feature | Chrome / Edge | Firefox | Safari (macOS / iOS) |
|---|---|---|---|
install, activate, ServiceWorker.state, statechange, updatefound, controllerchange | ✅ 40 / 17 | ✅ 44 | ✅ 11.1 / 11.3 |
self.skipWaiting() | ✅ 41 / 17 | ✅ 44 | ✅ 11.1 / 11.3 |
clients.claim() | ✅ 42 / 17 | ✅ 44 | ✅ 11.1 / 11.3 |
Async waitUntil() | ✅ 60 / 17 | ✅ 53 | ✅ 11.1 / 11.3 |
Byte-for-byte check of importScripts() URLs during updates | ✅ 78 / 79 | ✅ 56 | ✅ |
install dispatched as InstallEvent with addRoutes() | ✅ 123 / 123 | ❌ | ✅ 27 / 27 |
self.serviceWorker (a worker reading its own state) | ✅ 79 / 79 | ❌ | ✅ 15.4 / 15.4 |
ES module workers (type: "module") | ✅ 91 / 91 | ✅ 147 | ✅ 15 / 15 |
Edge versions below 79 are EdgeHTML. Firefox dispatches install as a plain ExtendableEvent (no addRoutes()), and so did Safari before version 27. Safari's byte comparison of imported scripts is implemented in WebKit's SWServerWorker::matchingImportedScripts(); its first shipping version is not documented, so no version number is given.
Debugging the lifecycle in DevTools¶
Chrome and Edge: a guided walkthrough¶
The quickest way to internalize the lifecycle is to watch it in DevTools → Application → Service workers (Chrome DevTools documentation):
- First install. Open a page that registers
/sw.jsin a fresh profile or after Application → Storage → Clear site data. The pane shows one entry,#N activated and is running. In the Console,navigator.serviceWorker.controllerisnull: the page is not controlled. Reload normally and it returns aServiceWorkerobject. - Create an update. Change any byte in
sw.js(bump theVERSIONconstant) and reload. A second entry appears:#N+1 waiting to activate, with a skipWaiting link. The Update Cycle table lists the Install, Wait and Activate phases with timings. - Prove the refresh rule. Reload again, as many times as you like: the new version stays waiting, for the reason shown in the sequence diagram above.
- End the wait. Either close every tab of the origin (open a new one afterwards) or click skipWaiting. The old entry disappears; if you used skipWaiting with the page open,
controllerchangefires in that page. - Watch termination. Click Stop next to the running worker (or close DevTools, wait more than 30 seconds and reopen it, because an attached DevTools session can keep the worker alive). The status changes to stopped. Trigger a request and the worker starts again: your top-level
script-evaluatedlog (from the logging snippet above) appears a second time, with noinstalloractivate. - Try a forced reload. Press Ctrl+Shift+R (Cmd+Shift+R on macOS). The Network panel shows requests going to the network without the "(ServiceWorker)" marker, and
navigator.serviceWorker.controllerisnull.
Two checkboxes change the lifecycle for development, and both should be off when you are testing real behavior:
- Update on reload makes each navigation force an update with the HTTP cache bypassed and the byte comparison skipped (Chromium passes
force_bypass_cacheandskip_script_comparisoninservice_worker_controllee_request_handler.cc), so a fresh version installs on every reload, and it skips the waiting phase. You will never see a waiting worker with it enabled. - Bypass for network sends requests to the network instead of the worker, but the worker still installs and activates.
For a view across all origins, chrome://serviceworker-internals lists every registration with its versions, running status and fetch handler type, and chrome://inspect/#service-workers lets you attach DevTools to any running worker. Remember that an attached DevTools session keeps the worker alive in Chromium.
Firefox¶
about:debugging#/runtime/this-firefox lists every registered service worker with its status (running or stopped) and Start, Inspect and Unregister buttons. The Application → Service Workers panel in Firefox DevTools shows the registration for the current page. Firefox has no skipWaiting button; post a message from the page console instead:
const reg = await navigator.serviceWorker.getRegistration();
reg.waiting?.postMessage({ type: "SKIP_WAITING" });
Safari¶
Enable the Develop menu (Safari → Settings → Advanced → Show features for web developers), then use Develop → Service Workers to open a Web Inspector attached to a running worker of an origin. For Home Screen web apps on iOS and iPadOS, connect the device to a Mac and use the device's entry in the Develop menu. The same console snippet works for activating a waiting worker.
More debugging workflows, including offline simulation and push testing, are on Browser DevTools; for testing lifecycle transitions in CI with Playwright or Puppeteer see Automated Testing.
Common pitfalls¶
- Assuming
register()resolving means installed. It resolves when installation starts. Observestatechangeto learn the outcome. - Assuming
readymeans controlled.readyresolves when an active worker exists for the scope, including on a first visit and on force-reloaded pages, wherecontrollerisnull. - Showing an update banner on the first visit. A first install passes through
installedtoo; gate update UI on an existing controller. - Deleting caches in
install. The old version is still serving pages from them. Delete inactivate, and only caches you own. - Heavy work in
activatecombined withskipWaiting()orclaim(). Every request from switched pages waits for it. - Calling
waitUntil()afterawait. ThrowsInvalidStateError; forinstall, the worker then installs without its caches. - Versioning the worker's filename. Pages served from the old cache keep registering the old URL. Keep
/sw.jsstable. - Reload loops on
controllerchange. Guard with a flag, and do not reload pages that had no controller at load. - Testing with "Update on reload" enabled. It hides the waiting phase entirely, so real users see behavior you never did.
- Relying on globals set in
installoractivate. The worker restarts without re-running either event.
The broader catalog of production failures is on Pitfalls & Anti-Patterns.
Further reading¶
On this site
- Service Workers overview: global scope, available APIs, idle termination and every event
- Registration & Scope:
register()options, scope matching andService-Worker-Allowed - Updating Service Workers: update checks,
updateViaCacheand update UX strategies - Messaging & the Clients API:
postMessage(),clients.matchAll()andclaim()in context - Precaching & Runtime Caching: what to cache in
installand what to cache later - IndexedDB: schema migrations that survive version overlap
- Workbox Fundamentals: lifecycle handling with
workbox-window - Browser DevTools: inspecting registrations and workers in every browser
External references
- Service Workers specification: Install, Activate and Try Activate algorithms
- web.dev: The service worker lifecycle (Jake Archibald)
- MDN: ServiceWorker.state
- MDN: ServiceWorkerGlobalScope.skipWaiting()
- MDN: Clients.claim()
- MDN: ExtendableEvent.waitUntil()
- Chrome for Developers: Fresher service workers, by default
- Chrome DevTools: Debug Progressive Web Apps