Updating Service Workers¶
A service worker update is the browser noticing that the script behind an existing registration has changed, installing the new version next to the one that is running, and handing control over only when it is safe to do so. The mechanism is deliberately conservative: without intervention a new version can wait for days while users keep a tab open, and when you do intervene, you can end up with an old page talking to a new worker, a reload loop, or a lazy-loaded chunk that no longer exists anywhere. This page walks through the update algorithm at spec level, how the HTTP cache participates, and the production patterns for shipping, activating, rolling back and killing service worker versions safely.
Key takeaways
- The browser checks for updates on every navigation into scope, on functional events such as
pushandsyncwhen the last check is more than 24 hours old, and whenever you callregistration.update(). A plainregister()call with the same URL is not an update check. - An update is detected by a byte-for-byte comparison of the main script and of every script it imported with
importScripts(). Changes to your HTML, CSS or JS bundles are invisible unless the worker's bytes change too. updateViaCachedefaults to"imports": the main script always revalidates with the server, while imported scripts may come from the HTTP cache. Whatever the mode, a registration not checked for more than 24 hours bypasses the cache.- A new worker waits until no client uses the old one.
skipWaiting()removes the wait but creates version skew between already-open pages and the new worker. - The safest default for apps with precached assets is prompting the user ("New version available, reload") and reloading exactly once on
controllerchange. - Recovery is always possible because the update request itself is never intercepted by a service worker. Keep the worker at a stable URL, never let it 404, and keep a kill-switch worker ready.
What counts as an update¶
The browser only knows about one file: the script URL stored in the registration. An "update" happens when the Update algorithm decides the fetched script differs from the newest worker in the registration (the installing worker if there is one, otherwise the waiting worker, otherwise the active worker). According to the Service Workers specification, a new worker is created when any of these is true:
| Condition | Example | New worker? |
|---|---|---|
| No worker exists yet | First visit | Yes |
| The main script's bytes differ | You changed one character, a comment, or a build hash embedded in it | Yes |
| An imported classic script's bytes differ | importScripts("/sw-lib.js") and sw-lib.js changed | Yes |
| The script URL changed | register("/sw-v2.js") for an existing scope | Yes, even if the bytes are identical |
| The worker type changed | Same URL, type: "classic" to type: "module" | Yes |
Only updateViaCache changed | Same URL, "imports" to "none" | No. The mode is updated on the registration in place. |
| Only the response headers changed | New Cache-Control or CSP on sw.js | No |
| Only the app's HTML, CSS or bundles changed | New index.html and app.3f9a.js | No, unless those changes alter the worker's bytes |
The last row is why every build tool that generates a service worker embeds a precache manifest (a list of URLs with content hashes or revision strings) directly in the worker. Changing any precached asset changes the manifest, which changes the worker's bytes, which triggers an update. If you write your worker by hand, embed a version constant or the build's asset manifest so a deploy always produces different bytes:
// Injected by the build. Any deploy that changes an asset changes these bytes,
// which is what makes the browser consider this an update.
const BUILD_ID = "2026-09-25T10:42:17Z+7c1e9b2";
const PRECACHE_MANIFEST = [
{ url: "/", revision: "a3f9c1" },
{ url: "/assets/app.5d41402a.js", revision: null }, // hash in filename
{ url: "/assets/app.7b8b965a.css", revision: null },
];
Conversely, a build that changes the worker's bytes on every deploy (a timestamp that changes even when nothing else does) forces every user through a reinstall on every deploy. That is harmless for correctness but wastes bandwidth and battery, so prefer content-derived identifiers.
When the browser checks for updates¶
The spec defines a Soft Update algorithm that browsers run on their own, and the update() method you call explicitly. Soft updates are fire-and-forget: they have no promise and no client, and if they fail (offline, 404, bad MIME type) nothing is reported to your code. At most, DevTools logs the failure.
| Trigger | Spec condition | Notes |
|---|---|---|
| Navigation into scope | Every non-subresource request handled for the registration: page navigations, iframe navigations, and dedicated or shared worker script requests | Runs regardless of how recently the last check happened. A force reload (Shift + reload) bypasses the worker entirely and does not trigger a check. |
| Subresource fetch events | Only when the registration is stale: more than 86,400 seconds since the last update check | Covers long-lived pages that never navigate. |
| Functional events | After dispatching push, sync, periodicsync, notificationclick and other functional events, if the registration is stale | Keeps workers that are only woken by pushes from running a months-old version. |
registration.update() | Always | Returns a promise. Chromium rate-limits calls from a worker that controls no clients (see below). |
register() with a different script URL or type | Always | Same URL, type and updateViaCache resolves without any fetch. |
| DevTools | "Update", "Update on reload" | Can force-bypass the HTTP cache. |
The spec also defines Request Soft Update, a hook other specifications can call to ask for an update check. It explicitly lets the browser rate-limit or coalesce those requests, so nothing built on it can promise that a check happens.
flowchart TD
N["Navigation into scope"] --> S["Soft Update"]
F["Subresource fetch event"] --> T{"Registration stale (over 24 h)?"}
E["push / sync / periodicsync / notificationclick"] --> T
T -- yes --> S
T -- no --> X["No check"]
U["registration.update()"] --> J["Update job with a promise"]
R["register() with new URL or type"] --> J
S --> Q["Per-scope job queue"]
J --> Q
Q --> A["Update algorithm: fetch, compare, install"] What Chromium does with navigation-triggered checks¶
The spec says only that a check happens; the timing is up to the browser. In current Chromium source, a navigation served by a worker leaves a pending "update hint" on that worker. The hint is released when the renderer reports that the page has gone network-quiet (Blink's idleness detector fires once parsing has finished and no requests have been active for a short window), or when the page goes away before that. When the last pending hint is released, the update is scheduled on a one-second timer (kUpdateDelay = 1000 ms), and a timer that is already running is restarted instead of starting a second one. The practical effect is that the update request does not compete with the page's own critical requests, and a burst of navigations produces a single check.
Chromium also throttles update() calls from inside a service worker that controls no clients, to stop a worker from keeping itself alive by updating in a loop. The first call runs immediately; after that, each call is delayed, starting at 30 seconds and doubling (30 s, 60 s, 120 s). Once the delay has doubled past the three-minute cap (kMaxSelfUpdateDelay), the next call is rejected immediately with an AbortError ("Service worker self-update limit exceeded."). A message from a client resets the backoff. Calls from pages, and from workers that do control clients, are not delayed.
update() has two spec-defined rejections of its own, both InvalidStateError: the registration has no worker at all (nothing to compare against, for example after a failed first install), or the method was called from inside a worker that is still installing. A third one, TypeError, occurs when the registration's newest worker has a different script URL from the one the update job was created with, which can happen when a register() call with a new URL races an update().
What does not trigger a check¶
- Calling
navigator.serviceWorker.register("/sw.js")on every page load with the same arguments. The Register algorithm resolves with the existing registration without touching the network. (The navigation that loaded the page already triggered a check.) - A single-page app changing routes with
history.pushState(). No navigation, no check. Long-lived SPAs therefore need periodic checks. - An installed PWA being brought back from the background on mobile, if the operating system kept the page alive. Nothing navigates. A
visibilitychangelistener is the usual fix. - A force reload. The navigation bypasses the service worker and the Handle Fetch algorithm returns before the check is scheduled.
The update check, step by step¶
Every update, whether from a soft update, update() or register(), runs the same Update algorithm in the registration's job queue. For a classic worker it proceeds like this:
- Fetch the main script with the destination
serviceworker, the headerService-Worker: script, service-workers modenone(the request is never intercepted by any worker), and redirect modeerror. - Choose the cache mode. The request uses the Fetch cache mode
no-cache(always revalidate with the server) if the registration'supdateViaCacheis not"all", if the job forces a cache bypass (DevTools), or if the registration is stale. Otherwise the HTTP cache may answer. - Validate the response. The status must be OK, the MIME type must be JavaScript (otherwise
SecurityError), and the scope must still be within the maximum scope, takingService-Worker-Allowedinto account (otherwiseSecurityError). - Record the check. If the response did not come from the local HTTP cache (a network response or a
304revalidation both count), the registration's last update check time is set to now. This timestamp drives the 24-hour staleness rule. - Compare the main script. If there is no newest worker, or its script URL or type differs, or the body is not byte-for-byte identical to what the newest worker stored, the update is real.
- Compare imported scripts. If the main script is identical and the newest worker used
importScripts(), fetch each imported URL again and compare. Imported scripts that now fail (404, network error, wrong MIME type) are ignored for this comparison; only good responses count. - If nothing changed, set the registration's
updateViaCacheto the job's mode, resolve the job's promise with the registration, and stop. - If something changed, create a new service worker, run its script (top-level evaluation). If evaluation throws, reject with
TypeError. The incumbent worker is unaffected. - Install: the new worker becomes
registration.installing, the job's promise resolves,updatefoundfires on everyServiceWorkerRegistrationobject for this registration in every same-origin page, and theinstallevent is dispatched.
sequenceDiagram
participant UA as Browser
participant Net as Server or HTTP cache
participant Old as Active worker (v1)
participant New as New worker (v2)
UA->>Net: GET /sw.js (Service-Worker: script, no-cache)
Net-->>UA: 200 or 304
Note over UA: MIME, status and max scope checks, last update check time set
alt Bytes identical, imports identical
UA-->>UA: Done. v1 keeps running.
else Bytes differ
UA->>New: Evaluate script
UA-->>UA: registration.installing = v2, updatefound
UA->>New: install event
New-->>UA: waitUntil() settles
Note over Old,New: v2 is now "installed" (waiting) while v1 still controls pages
end Byte-for-byte means bytes¶
The comparison is on the response body bytes after content decoding, so switching from gzip to Brotli does not trigger an update, but anything that alters the decoded bytes does: a comment, whitespace, a different minifier version, reordered object keys, a source map comment with a new hash. Two consequences are worth designing for:
- Non-deterministic builds cause phantom updates. If your bundler emits different bytes for the same source (timestamps, absolute paths, unstable chunk IDs), every deploy is an update even when nothing changed.
- Different bytes per server cause flapping. If a load balancer serves
sw.jsfrom two builds during a rolling deploy, users bounce between versions on consecutive navigations. Deploy the worker last, after every node serves the new assets, or pinsw.jsto one origin server.
Imported scripts¶
Classic workers can pull in code with importScripts(). The rules around imported scripts shape how you should structure and version them:
- Imports are captured at install time. Every URL imported while the worker is
parsedorinstallingis fetched and stored in the worker's script resource map. After installation,importScripts()can only return scripts already in that map. Importing a new URL later (for example lazily in afetchhandler) fails with aNetworkError. - Unused imports are dropped. At the end of installation, entries that were not actually used are removed from the map.
- Imports participate in update checks. Chrome 78 and later compare each stored import byte-for-byte during update checks, matching what Firefox shipped in Firefox 56 and what Safari already did, according to Chrome's announcement. A changed import triggers the full update flow even if the main script is identical.
- Versioned import URLs make this moot. If you import
/sw-lib.4f2a9c.js, the main script's bytes change whenever the hash changes, so the main-script comparison already detects the update.
Module workers (type: "module") fetch their static import graph during installation too, and dynamic import() is forbidden. Here the spec and Chromium differ:
- The spec's Update algorithm re-fetches and byte-compares imported scripts only when the newest worker's classic scripts imported flag is set, which only
importScripts()sets. For a module worker it re-fetches the whole static graph, but the byte-for-byte comparison covers only the top-level script. - Chromium's update checker compares every script resource it stored for the worker, which includes statically imported modules. web.dev's article on ES modules in service workers says so explicitly: "Scripts imported via ES modules can trigger the service worker update flow if their contents change, matching the behavior of importScripts()."
Do not depend on either behavior. If you ship module workers to several engines, make sure a change to any imported module also changes the top-level file. Content-hashed import specifiers (which bundlers generate) do exactly that, because the new hash appears in the top-level import statement.
updateViaCache and the HTTP cache¶
Until Chrome 68, the HTTP cache could answer the update check for the main script, subject to a rule that treated any max-age above 86,400 seconds as 86,400, "to avoid users being stuck with a particular version forever". Since Chrome 68, and in Firefox and Safari, the updateViaCache registration option decides which requests may use the HTTP cache:
updateViaCache | Main script (/sw.js) | Imported scripts | Typical use |
|---|---|---|---|
"imports" (default) | Always revalidated with the server (no-cache) | HTTP cache allowed | Hash-named imports served with long max-age and immutable. |
"all" | HTTP cache allowed | HTTP cache allowed | Rare. Only if you want HTTP caching to rate-limit update checks. |
"none" | Always revalidated | Always revalidated | Imports at stable, unhashed URLs. |
Two further rules apply in every mode:
- The 24-hour rule is a backstop, not a cap on every import. If the registration is stale (more than 86,400 seconds since the last update check that reached the network), the main script and the imports are all fetched with
no-cache. With"all", that bounds how long HTTP caching can hide a new main script to roughly a day. With the default"imports", however, the main script revalidates on every check, which resets the clock each time, so for a regularly used app the registration rarely becomes stale. A stable-URL import with a longmax-agecan then be answered from the HTTP cache for as long as its own headers allow, and a change to it goes unnoticed. no-cacheis revalidation, not a full download. It sends a conditional request when the browser has a cached copy, so an unchanged worker costs one round trip and a304 Not Modified. The Fetch standard also addsCache-Control: max-age=0to such requests. A304still counts as a network response for the last-update-check timestamp.
Set the mode when registering. Changing it later is just another register() call with the new value:
const registration = await navigator.serviceWorker.register("/sw.js", {
updateViaCache: "none", // imports live at stable URLs in this app
});
console.log(registration.updateViaCache); // "none"
Response headers for the worker and its imports¶
updateViaCache controls the browser's HTTP cache, not caches between the browser and your server. A CDN or reverse proxy that caches sw.js for an hour answers the browser's revalidation with the stale file for an hour. Set explicit headers:
| Resource | Recommended Cache-Control | Why |
|---|---|---|
/sw.js (stable URL) | no-cache or max-age=0, must-revalidate | Every check reaches your origin or a CDN that revalidates. Keep the file small so the check is cheap. |
Hash-named imports (/sw-lib.4f2a9c.js) | public, max-age=31536000, immutable | The URL changes when the content does. |
Stable-URL imports (/sw-lib.js) | no-cache (or register with updateViaCache: "none") | Otherwise the default mode lets the HTTP cache answer import checks for as long as the import's max-age allows. |
index.html and other navigations | no-cache | Stale HTML referencing deleted chunks is the root of most lazy-load failures. |
If the CDN caches sw.js anyway, purge it as the last step of each deploy, after the new assets are live everywhere.
How the new version takes over¶
Once installed, the new worker waits. The full lifecycle is covered on Lifecycle; what matters for updates is the exact condition under which the waiting worker activates. The spec's Try Activate runs Activate when the registration has a waiting worker, the current active worker is not itself still activating, and either:
- no service worker client is using the registration, or the waiting worker's skip-waiting flag is set,
and the active worker has no pending events: no fetch, message or other extendable event whose respondWith() or waitUntil() promises are still unsettled. So even skipWaiting() does not cut off a long-running fetch response or a background upload in the old worker: activation waits for them to settle.
That wait is not unbounded in practice. Chromium calls an outgoing worker a "lame duck" once a waiting worker has called skipWaiting(), or once the outgoing worker has no controlled clients left. It asks a running lame duck to go idle as soon as possible and gives it at most five minutes (kMaxLameDuckTime in Chromium's source) to finish its in-flight events. After that, the waiting worker is activated even if the old one still has pending requests. A streaming response that the old worker keeps open for longer than that can therefore be cut short by an update.
When Activate runs:
- The old active worker is terminated and becomes
redundant. - The waiting worker becomes the active worker, in the
activatingstate. - Pending
navigator.serviceWorker.readypromises in matching clients resolve. - Every client that was using the registration switches to the new worker, and each receives
controllerchange. This happens whether or not the new worker callsclients.claim().claim()is only about clients that were not controlled at all. - The
activateevent is dispatched. While the worker isactivating, incomingfetchand functional events wait until it reachesactivated, so a slowactivatehandler stalls every request from every open page.
Why a reload does not activate the waiting worker¶
Reloading the only open tab rarely lets the waiting worker activate. The navigation request for the reload is handled by the old active worker, and the new document is created (as a client of the old worker) before the old document unloads, so the number of clients using the registration never reaches zero. The user has to close every tab and window in scope, or navigate all of them away, or you have to call skipWaiting().
Other details that shape update behavior¶
- A newer version replaces a waiting one. If v2 is waiting and v3 finishes installing, v2 is terminated and becomes
redundant, and v3 waits instead. Users never have to step through intermediate versions. - Browser restarts promote waiting workers. The spec's shutdown rules say a waiting worker becomes the active worker when the browser restarts, and an installing worker is discarded. On desktop this is often how updates land for users who never close their tabs by hand.
- Failed installs leave the old version untouched. If
installrejects (a precache request failed), the new worker becomesredundantand the next navigation tries again. - Activation evicts back/forward-cached pages in Chromium. When a new worker activates, Chromium evicts any bfcached pages that the outgoing worker controlled, so pressing Back after an update loads a fresh page (served by the new worker) instead of restoring a snapshot that still runs old code against a new worker.
The events the page can observe, in order, for a successful update:
sequenceDiagram
participant Page
participant Reg as ServiceWorkerRegistration
participant W as New ServiceWorker object
Note over Reg: Update found
Reg->>Page: updatefound, reg.installing is W
W->>Page: statechange to installed, reg.waiting is W
Note over Page,W: Waits here until no clients or skipWaiting()
W->>Page: statechange to activating, reg.active is W
Page->>Page: controllerchange, controller is W
W->>Page: statechange to activated The ordering between controllerchange and the statechange to activating is not something to depend on, because they are separate queued tasks. Code that reacts to one should not assume the other has already fired.
Detecting a waiting worker reliably¶
The page may learn about a waiting worker in three different ways, and robust code handles all of them:
- the update happened before this page loaded (another tab triggered it, or a previous visit left it waiting), so
registration.waitingis already set; - the update is in progress when the page registers, so
registration.installingis set; - the update is found later (by this page's navigation check, a periodic
update(), or another tab), soupdatefoundfires.
/**
* Calls `onWaiting(worker)` once for every worker that reaches the waiting
* state while an older worker controls this page. Returns an unsubscribe.
*/
export function whenWaiting(registration, onWaiting) {
const seen = new WeakSet();
const report = (worker) => {
if (!worker || seen.has(worker)) return;
// A waiting worker on a page with no controller is a first install
// blocked by another tab, not an update of *this* page.
if (!navigator.serviceWorker.controller) return;
// It may already be activating if it called skipWaiting() during install.
if (registration.waiting !== worker) return;
seen.add(worker);
onWaiting(worker);
};
const track = (worker) => {
if (!worker) return;
if (worker.state === "installed") report(worker);
worker.addEventListener("statechange", () => {
if (worker.state === "installed") report(worker);
});
};
report(registration.waiting);
track(registration.installing);
const onUpdateFound = () => track(registration.installing);
registration.addEventListener("updatefound", onUpdateFound);
return () => registration.removeEventListener("updatefound", onUpdateFound);
}
Update patterns¶
There is no single correct policy. The right one depends on whether your worker precaches versioned assets, whether pages hold unsaved state, and whether old pages can talk to new workers.
| Pattern | Activation | User impact | Main risk | Good fit |
|---|---|---|---|---|
| Default wait | When every in-scope tab is closed | None, but updates can take days | Stale versions for users who never close tabs | Content sites, workers with no precache |
skipWaiting() immediately | Right after install | Open pages silently get a new worker | Version skew: old pages, new worker and caches | Workers that only do network-first or push |
| Prompt the user | When the user accepts | One click and a reload | Needs UI and a reload guard | App shells with precached, hashed assets |
| Activate on next navigation | At the next in-app route change | A full page load instead of a client-side route change | SPA router integration | SPAs where a reload at a route change is acceptable |
skipWaiting() plus auto-reload | Right after install | Page reloads under the user | Lost form state, reload loops | Kiosks, dashboards without user input |
The sections below give the mechanics, a sequence diagram and code for each.
Pattern 1: the default wait¶
Do nothing special. The new worker installs in the background and waits; it activates when every tab in scope is closed, or when the browser restarts.
sequenceDiagram
participant Tab as Open tab (v1 page)
participant V1 as Worker v1 (active)
participant V2 as Worker v2
Tab->>V1: Navigation triggers update check
V1-->>Tab: Page served by v1
Note over V2: v2 installs, then waits
Tab->>V1: More navigations, all still served by v1
Note over Tab: User closes the last tab
V2->>V2: activate (v1 becomes redundant)
Note over Tab: Next visit is served by v2 This is the only pattern with zero version skew: a page always talks to the worker version that served it. The cost is latency, which is worse than it looks for installed apps. Mobile operating systems keep PWAs suspended rather than closed, and users on desktop keep pinned tabs open for weeks. Pair the default wait with at least a "new version available" hint, or accept that some users will run old code for a long time.
Pattern 2: immediate activation with skipWaiting()¶
Calling self.skipWaiting() sets the worker's skip-waiting flag, so Try Activate no longer waits for clients to go away. It can be called at any point before or during waiting; calling it in install is the common choice:
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
await precacheCurrentVersion(); // if this rejects, the install fails
// Activate as soon as installation succeeds, without waiting for tabs.
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
// Take control of pages that were never controlled (first install).
event.waitUntil(self.clients.claim());
});
What happens to pages that are already open:
sequenceDiagram
participant Page as Open page (v1 HTML and JS)
participant V1 as Worker v1
participant V2 as Worker v2
Note over V2: Installs, calls skipWaiting()
V2->>V1: Activate: v1 becomes redundant
V2-->>Page: controllerchange (page now controlled by v2)
Page->>V2: import("/assets/settings.v1hash.js")
V2-->>Page: Not in v2 precache, network 404 after deploy
Note over Page: Lazy route fails in the v1 page The page is still running v1's HTML and JavaScript, but every request it makes now goes through v2, which only knows v2's precache and may have already deleted v1's caches in its activate handler. Any lazy-loaded chunk, message format or IndexedDB schema that differs between versions is now a mismatch. skipWaiting() is safe when:
- the worker does not precache versioned app code (it only does network-first caching, push, or offline fallback pages);
- the worker keeps old caches around long enough and falls back across them (see keeping old caches);
- or every page reloads right after the switch, which is Pattern 5 with its own risks.
Pattern 3: prompt the user to reload¶
The new worker installs and waits. The page notices the waiting worker, shows a non-blocking prompt, and when the user accepts, tells the waiting worker to call skipWaiting(). When control changes, the page reloads once and comes back fully on the new version.
sequenceDiagram
participant User
participant Page as Page (v1)
participant V2 as Worker v2 (waiting)
participant V1 as Worker v1 (active)
Note over V2: Installed and waiting
Page->>User: "A new version is available. Reload?"
User->>Page: Clicks Reload
Page->>V2: postMessage SKIP_WAITING
V2->>V2: self.skipWaiting()
V2->>V1: Activate, v1 becomes redundant
V2-->>Page: controllerchange
Page->>Page: location.reload() exactly once
Page->>V2: Navigation served by v2 The worker side is a message listener. This is the same handler Workbox's generated workers include when their skipWaiting option is off:
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") {
// Harmless if this worker is already active: there is nothing to skip.
self.skipWaiting();
}
});
The page side needs three things: detection (the whenWaiting() helper above), a prompt, and a reload guard. Both a dependency-free version and a Workbox version follow.
import { whenWaiting } from "./when-waiting.js";
const RELOAD_GUARD_KEY = "sw-update-reloaded-at";
function reloadedRecently(windowMs = 10_000) {
try {
const at = Number(sessionStorage.getItem(RELOAD_GUARD_KEY));
return Number.isFinite(at) && Date.now() - at < windowMs;
} catch {
return false; // storage unavailable: fall back to the in-memory guard
}
}
function markReload() {
try {
sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now()));
} catch {
/* ignore */
}
}
/**
* @param {ServiceWorkerRegistration} registration
* @param {{ prompt: (api: { accept(): void, dismiss(): void }) => void,
* onStaleTab?: () => void }} ui
*/
export function initUpdateFlow(registration, ui) {
const container = navigator.serviceWorker;
// Pages loaded without a controller get controllerchange from
// clients.claim() on first install. That is not an update: never reload.
const hadControllerAtLoad = Boolean(container.controller);
let acceptedHere = false;
let reloading = false;
container.addEventListener("controllerchange", () => {
if (!hadControllerAtLoad || reloading) return;
if (!acceptedHere) {
// Another tab accepted the update, or the new worker called
// skipWaiting() itself. This tab still runs old code: tell the user
// instead of reloading under them.
ui.onStaleTab?.();
return;
}
if (reloadedRecently()) return; // loop breaker
reloading = true;
markReload();
window.location.reload();
});
whenWaiting(registration, (waitingWorker) => {
ui.prompt({
accept() {
acceptedHere = true;
if (registration.waiting !== waitingWorker) {
// Another tab already activated this worker (or a newer one
// replaced it). No controllerchange will follow in this tab,
// so posting SKIP_WAITING would leave the prompt hanging.
reloading = true;
markReload();
window.location.reload();
return;
}
waitingWorker.postMessage({ type: "SKIP_WAITING" });
},
dismiss() {
// Keep waiting. It activates when all tabs close, or on the next
// prompt after another update check.
},
});
});
}
import { Workbox } from "workbox-window";
export function initWorkboxUpdateFlow(ui) {
if (!("serviceWorker" in navigator)) return null;
const wb = new Workbox("/sw.js");
let acceptedHere = false;
let controllerChangedElsewhere = false;
let reloading = false; // never reload twice (controllerchange can repeat)
// Fires when a new worker is installed and waiting. `event.isExternal`
// is true when the update was most likely triggered by another tab;
// `event.wasWaitingBeforeRegister` when it was already waiting at load.
wb.addEventListener("waiting", () => {
ui.prompt({
accept() {
acceptedHere = true;
if (controllerChangedElsewhere) {
// Another tab already activated the new worker: nothing is
// waiting any more, so messageSkipWaiting() would do nothing.
window.location.reload();
return;
}
// Posts { type: "SKIP_WAITING" } to registration.waiting.
wb.messageSkipWaiting();
},
dismiss() {},
});
});
// Fires on every controllerchange. Only reload if this tab asked for it.
wb.addEventListener("controlling", (event) => {
if (acceptedHere) {
if (reloading) return;
reloading = true;
window.location.reload();
} else if (event.isUpdate || event.isExternal) {
controllerChangedElsewhere = true;
// isUpdate: a controller existed when this page registered, so
// this is a replacement, not the first install claiming the page.
// isExternal: the new controller is not the worker we registered.
ui.onStaleTab?.();
}
});
// Waits for the window load event before registering (default).
wb.register();
return wb;
}
A few notes on the Workbox version, based on the current workbox-window source:
wb.register()waits forloadunless you pass{ immediate: true }.- The
waitingevent is dispatched about 200 ms after the worker reachesinstalled, and only if it is still waiting, which filters out workers that calledskipWaiting()during install. - Only the first
updatefoundafterwb.register()counts as "this page's" worker, and only if it fires within 60 seconds of registration and for the same script URL. Any laterupdatefound, one for a different script URL, or one after the 60-second window is treated as external, and the resulting events carryisExternal: true. In practice, every update found by a periodicwb.update()in a long-lived tab is external. - The
controllingevent fires on everycontrollerchange; itsisExternalistruewhenever the new controller is not the worker this instance registered. messageSkipWaiting()sends{ type: "SKIP_WAITING" }toregistration.waiting, and does nothing if no worker is waiting.- Build-generated Workbox workers (
generateSWwithskipWaiting: false) already contain the matching message listener.
If you use the Vite PWA plugin, its registerType: "prompt" mode wires the same flow through a virtual:pwa-register module. See Vite PWA Plugin and Workbox Fundamentals.
A minimal accessible prompt, rendered as a polite live region so screen readers announce it without stealing focus:
export function showUpdateToast({ accept, dismiss }) {
if (document.getElementById("sw-update-toast")) return; // one at a time
const toast = document.createElement("div");
toast.id = "sw-update-toast";
toast.className = "update-toast";
toast.setAttribute("role", "status"); // implicit aria-live="polite"
const text = document.createElement("p");
text.textContent = "A new version of this app is available.";
const reload = document.createElement("button");
reload.type = "button";
reload.textContent = "Reload";
reload.addEventListener("click", () => {
reload.disabled = true;
reload.textContent = "Updating…";
accept();
});
const later = document.createElement("button");
later.type = "button";
later.textContent = "Not now";
later.addEventListener("click", () => {
toast.remove();
dismiss();
});
toast.append(text, reload, later);
document.body.append(toast);
}
UX guidance that matters in practice:
- Don't interrupt. Never use
confirm()or a modal; don't show the prompt while a form is dirty or an upload is running. Queue it until the user is idle or navigates. - Say what reloading does. If the app has unsaved state, persist it first (for example to IndexedDB) and restore it after the reload.
- Handle the other tabs. When the user accepts in one tab, every other tab gets
controllerchangetoo, and they are now old pages talking to a new worker. The code above callsonStaleTab()for them: show a persistent "This tab is out of date. Reload" banner rather than reloading silently. - More on update UX is on App-Like UX Patterns and Accessibility.
Guarding against controllerchange reload loops¶
"Reload on controllerchange" is one line of code and a classic source of infinite reload loops. The loop needs two ingredients: something that triggers controllerchange on every page load, and a handler that reloads unconditionally.
stateDiagram-v2
[*] --> PageLoads
PageLoads --> UpdateInstalled: navigation triggers update
UpdateInstalled --> ControllerChange: skipWaiting or DevTools update on reload
ControllerChange --> PageLoads: handler calls location.reload
ControllerChange --> Stable: guard sees a recent reload or no user request
Stable --> [*] Known triggers of repeated controller changes:
| Trigger | Why it fires on every load | Guard |
|---|---|---|
| DevTools "Update on reload" | Each navigation installs the worker as a new version and skips waiting | Only reload when the user accepted in this tab |
A worker whose bytes differ on every request (timestamp or nonce rendered into sw.js) with skipWaiting() | Every navigation finds an "update" | Fix the server; add the time-window guard |
clients.claim() on first install | The first page load goes from no controller to controlled | Ignore controllerchange if the page had no controller at load |
Two controllerchange listeners (framework plus your code) | Double reloads, or one reloading while the other prompts | Centralize the handler |
| Another tab accepting the update | controllerchange fires in every tab | Don't auto-reload tabs that didn't ask |
The initUpdateFlow() code above applies all three guards: hadControllerAtLoad, acceptedHere, and a sessionStorage time window that survives the reload itself.
Pattern 4: activate on the next navigation¶
For single-page apps, an elegant middle ground is to leave the prompt out entirely and apply the update at the next moment the user expects a "page change" anyway: the next in-app route transition. When a worker is waiting, the router performs a full navigation instead of a client-side route change, after asking the waiting worker to activate.
sequenceDiagram
participant User
participant Router as SPA router (v1)
participant V2 as Worker v2 (waiting)
User->>Router: Clicks "Settings"
Router->>Router: registration.waiting is set
Router->>V2: postMessage SKIP_WAITING
V2-->>Router: controllerchange
Router->>Router: location.assign("/settings") (full load)
Note over User,Router: Settings page arrives from v2 with v2 assets /**
* Call from your router's navigation hook, e.g. beforeEach(to) in Vue Router,
* a navigate listener, or your own link interceptor. Returns true if it took
* over the navigation.
*/
export async function maybeUpdateOnNavigate(registration, targetUrl) {
const waiting = registration?.waiting;
if (!waiting || !navigator.serviceWorker.controller) return false;
const switched = new Promise((resolve) =>
navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true }),
);
waiting.postMessage({ type: "SKIP_WAITING" });
// Don't hang the navigation if activation is delayed by pending events.
await Promise.race([switched, new Promise((r) => setTimeout(r, 3000))]);
window.location.assign(targetUrl); // full navigation, served by the new worker
return true;
}
This pattern never interrupts the user and never runs old page code against a new worker for longer than a route transition. It does cost one full page load, so preserve state that lives only in memory.
Pattern 5: skipWaiting() plus automatic reload¶
For screens without user input (wall dashboards, kiosks, signage), combine skipWaiting() in the worker with an unconditional, guarded reload on controllerchange. Keep the time-window guard from the vanilla code: a buggy deploy that changes the worker's bytes on every request would otherwise reload the screen forever. For anything with user input, prefer Pattern 3 or 4.
sequenceDiagram
participant Screen as Kiosk page (v1)
participant V1 as Worker v1 (active)
participant V2 as Worker v2
participant Server
Note over Screen,V1: Page is controlled by v1
Screen->>Server: Periodic registration.update() fetches /sw.js, never intercepted
Server-->>V2: New bytes, v2 installs
V2->>V2: skipWaiting() inside install
V2->>V1: Activate, v1 becomes redundant
V2-->>Screen: controllerchange
Screen->>Screen: Guard: no reload in the last 60 s?
Screen->>Server: location.reload(), page served by v2 The worker side is the skipWaiting()-in-install code from Pattern 2. The page side needs the controller-at-load check, a reload guard that survives the reload, and periodic checks, because a kiosk page never navigates on its own:
import { startUpdateChecks } from "./periodic-update-checks.js";
const GUARD_KEY = "kiosk-sw-reload-at";
const MIN_RELOAD_GAP_MS = 60_000;
function canReloadNow() {
try {
const last = Number(sessionStorage.getItem(GUARD_KEY));
if (Number.isFinite(last) && Date.now() - last < MIN_RELOAD_GAP_MS) return false;
sessionStorage.setItem(GUARD_KEY, String(Date.now()));
return true;
} catch {
return true; // storage unavailable: the in-memory flag below still stops double reloads
}
}
export async function initKioskAutoUpdate() {
const container = navigator.serviceWorker;
if (!container) return;
// First install with clients.claim() also fires controllerchange: not an update.
const hadControllerAtLoad = Boolean(container.controller);
let reloading = false;
container.addEventListener("controllerchange", () => {
if (!hadControllerAtLoad || reloading) return;
if (!canReloadNow()) {
// Something is producing a new worker on every load. Stay on the current
// page and report it instead of flapping the screen.
navigator.sendBeacon?.("/rum/sw-reload-loop", location.href);
return;
}
reloading = true;
location.reload();
});
const registration = await container.ready;
// Kiosks run for weeks: check every 15 minutes, and whenever the network returns.
startUpdateChecks(registration, { intervalMs: 15 * 60 * 1000 });
}
Two operational details matter for unattended screens. First, the reload must work even if the network drops between the update check and the reload. The reload is served by the new worker, so v2 has to precache its own shell before it activates. Calling skipWaiting() only after cache.addAll() succeeded inside install's waitUntil(), as Pattern 2 does, guarantees that. Second, schedule risky deploys for the hours when the screens matter least: every screen in the fleet reloads within one check interval of the deploy.
Periodic update checks for long-lived apps¶
A single-page app that stays open for days only gets checks from its initial navigation and, once the registration is stale, from subresource fetch events. Add explicit checks:
/**
* Checks for a new service worker periodically, when the tab becomes
* visible, and when connectivity returns. Returns a stop function.
*/
export function startUpdateChecks(
registration,
{ intervalMs = 60 * 60 * 1000, minGapMs = 60 * 1000 } = {},
) {
let lastCheck = 0;
let inFlight = false;
async function check(reason) {
if (inFlight || !navigator.onLine) return;
if (registration.installing) return; // an update is already installing
if (Date.now() - lastCheck < minGapMs) return;
lastCheck = Date.now();
inFlight = true;
try {
// Resolves once the check is done: either nothing changed, or a new
// worker has *started* installing (look at registration.installing).
await registration.update();
} catch (error) {
// TypeError: offline, 404/5xx, or the new script threw on evaluation.
// SecurityError: MIME type or scope problems on the server.
// InvalidStateError: the registration has no worker at all.
console.debug(`[sw] update check (${reason}) failed:`, error.name, error.message);
} finally {
inFlight = false;
}
}
const onVisibility = () => {
if (document.visibilityState === "visible") check("visible");
};
const onOnline = () => check("online");
const timer = setInterval(() => check("interval"), intervalMs);
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("online", onOnline);
return function stop() {
clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("online", onOnline);
};
}
sequenceDiagram
participant Page as Long-lived SPA
participant Reg as Registration
participant Server
loop Every hour, on visibility and on reconnect
Page->>Reg: registration.update()
Reg->>Server: GET /sw.js (conditional)
alt 304 Not Modified
Server-->>Reg: 304
Reg-->>Page: resolves, no installing worker
else 200 with new bytes
Server-->>Reg: 200
Reg-->>Page: resolves, updatefound, installing worker
Note over Page: whenWaiting() shows the prompt later
end
end Design notes:
- Don't check more often than you deploy. Hourly is a common default; the web.dev lifecycle guide suggests an interval "such as hourly". Each check is one conditional request.
- Background tabs throttle timers, so the interval alone is unreliable for hidden tabs. The
visibilitychangetrigger covers the moment the user returns, which is when an update matters. - Checks from inside the worker (calling
self.registration.update()from amessageorperiodicsynchandler) work too, but in Chromium they are rate-limited when the worker controls no clients, as described above. Periodic Background Sync is Chromium-only and gated on engagement, so it is a bonus, not a mechanism to rely on. - The Vite PWA plugin documents the same approach and additionally fetches the worker URL with
cache: "no-store"first, callingupdate()only when that returns200, so that a server outage doesn't produce a failed update check.
Keeping old pages and new workers compatible¶
Whichever pattern you choose, there is a window in which an old page runs against a new worker: with skipWaiting() it is the rest of the page's life, with a prompt it is until the user accepts, and with the default wait it only happens across tabs of a multi-tab user. Designing for that window is what separates robust PWAs from ones that break on deploy day.
Versioned caches and cleanup¶
The standard pattern: each version precaches into its own cache during install, and deletes older versions' caches during activate. Never delete old caches during install: the old worker is still serving pages from them.
const VERSION = "7c1e9b2"; // injected by the build
const PREFIX = "shop:"; // unique per app on this origin
const PRECACHE = `${PREFIX}precache:${VERSION}`;
const RUNTIME = `${PREFIX}runtime`;
const PRECACHE_URLS = [
"/",
"/offline.html",
"/assets/app.5d41402a.js",
"/assets/app.7b8b965a.css",
];
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(PRECACHE);
// cache: "reload" skips the HTTP cache so we never precache stale copies.
await cache.addAll(PRECACHE_URLS.map((url) => new Request(url, { cache: "reload" })));
})(),
);
});
Keeping the previous precache for old pages¶
If the new worker may control old pages (any pattern except the default wait), keep the previous version's precache for one generation instead of deleting it immediately, and let the fetch handler fall back across caches. caches.keys() returns cache names in creation order (the spec guarantees this), so "keep the newest two precaches" is easy to express:
const KEEP_PRECACHE_GENERATIONS = 2; // current + previous
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys(); // creation order, oldest first
const precaches = names.filter((n) => n.startsWith(`${PREFIX}precache:`));
const keep = new Set(precaches.slice(-KEEP_PRECACHE_GENERATIONS));
keep.add(PRECACHE); // paranoia: never delete our own
await Promise.all(
precaches.filter((n) => !keep.has(n)).map((n) => caches.delete(n)),
);
})(),
);
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method !== "GET" || url.origin !== location.origin) return;
if (url.pathname.startsWith("/assets/")) {
// Hashed assets: search every cache, so a v1 page asking for a v1 chunk
// after v2 activated is still served from v1's precache.
event.respondWith(
(async () => (await caches.match(event.request)) ?? fetch(event.request))(),
);
}
});
Workbox's precaching handles revisioning for you: its install step caches new or changed entries, and its activate step removes cached entries that are no longer in the current manifest. That cleanup is exactly what breaks old pages after skipWaiting(): the old page's chunks are no longer in the precache, so the request falls through to the network. If you combine Workbox precaching with immediate activation, keep old hashed assets on the server and handle stale-chunk errors as shown in the next sections. Precaching & Runtime Caching and Advanced Workbox cover the details.
Data migrations between versions¶
IndexedDB is shared by every page and worker of the origin, so a schema change is an origin-wide event, not a per-worker one:
- A connection opened with a higher version fires
versionchangeon every other open connection (old tabs, the old worker). Until they close, the new open request receivesblockedand theupgradeneededmigration does not run. - A connection opened with a lower version than the database's current version fails with a
VersionError. Old pages cannot open a database a newer version has upgraded. That makes data migrations one-way, which matters for rollbacks. - Code in both versions must close connections on
versionchange, or upgrades hang until the user closes tabs.
const DB_NAME = "shop";
const DB_VERSION = 3;
export function openDatabase({ onBlocked, onVersionChange } = {}) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = request.result;
const tx = request.transaction; // the versionchange transaction
// Migrations are cumulative and idempotent: each step checks oldVersion.
if (event.oldVersion < 1) {
db.createObjectStore("cart", { keyPath: "sku" });
}
if (event.oldVersion < 2) {
tx.objectStore("cart").createIndex("byAddedAt", "addedAt");
}
if (event.oldVersion < 3) {
// v3 stores prices in integer cents instead of float dollars.
const store = tx.objectStore("cart");
store.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) return;
const item = cursor.value;
if (typeof item.priceCents !== "number") {
item.priceCents = Math.round(item.price * 100);
delete item.price;
cursor.update(item);
}
cursor.continue();
};
}
};
// Another connection (an old tab or the old worker) didn't close.
request.onblocked = () => onBlocked?.();
request.onsuccess = () => {
const db = request.result;
// A newer version wants to upgrade: get out of its way.
db.onversionchange = () => {
db.close();
onVersionChange?.(); // pages: show "reload to continue"
};
resolve(db);
};
request.onerror = () => reject(request.error);
});
}
Where to run migrations: letting whichever context opens the database first run upgradeneeded is usually right. Avoid long migrations in the worker's activate handler. The spec notes that activation handlers "may not all run to completion", for example if the browser terminates during activation, and while the worker is activating, every fetch event from every open page waits for it. Keep activate to fast cleanup, and make any data migration resumable. More on schema design is on IndexedDB and Offline-First Data & Sync.
Versioning the page-to-worker message protocol¶
If pages and the worker exchange messages (sync requests, cache-status queries, auth tokens), include a protocol version and keep the worker backward compatible with the previous page version:
const PROTOCOL = 3;
self.addEventListener("message", (event) => {
const msg = event.data ?? {};
switch (msg.type) {
case "GET_VERSION":
// Reply over the MessageChannel port the page sent.
event.ports[0]?.postMessage({ build: VERSION, protocol: PROTOCOL });
break;
case "QUEUE_ORDER":
// v2 pages send { order }, v3 pages send { order, idempotencyKey }.
event.waitUntil(queueOrder(msg.order, msg.idempotencyKey ?? crypto.randomUUID()));
break;
case "SKIP_WAITING":
self.skipWaiting();
break;
default:
// Unknown message from a newer or older page: ignore, don't throw.
break;
}
});
export async function workerVersion(timeoutMs = 2000) {
const controller = navigator.serviceWorker.controller;
if (!controller) return null;
const { port1, port2 } = new MessageChannel();
const reply = new Promise((resolve) => {
port1.onmessage = (e) => resolve(e.data);
});
controller.postMessage({ type: "GET_VERSION" }, [port2]);
return Promise.race([reply, new Promise((r) => setTimeout(() => r(null), timeoutMs))]);
}
// Compare with the build ID baked into this page's bundle.
const info = await workerVersion();
if (info && info.build !== __BUILD_ID__) {
console.info(`Page ${__BUILD_ID__} is controlled by worker ${info.build}`);
}
Details of MessageChannel request/response patterns are on Messaging & the Clients API.
HTML and lazy-loaded chunk mismatches¶
Modern builds split JavaScript into content-hashed chunks and load most of them lazily with import(). After a deploy, three copies of "the app" can exist at once: the old HTML and entry chunk in a running page, the new files on the server, and whatever the service worker has cached. A lazy chunk request fails when the page asks for a file that exists in none of the places it can be served from:
sequenceDiagram
participant Page as Page running v1
participant SW as Worker
participant CDN as Server after v2 deploy
Page->>SW: import("/assets/chart.v1hash.js")
alt v1 chunk still cached (v1 precache kept)
SW-->>Page: 200 from cache
else cache cleaned up by v2 activate
SW->>CDN: fetch v1 chunk
alt Old assets retained on server
CDN-->>Page: 200
else Old assets deleted by deploy
CDN-->>Page: 404, dynamic import rejects
end
end Defenses, from most to least important:
- Keep old hashed assets on the server for several deploys (or at least several days). Hashed files never conflict, so an append-only asset bucket with a lifecycle rule is cheap insurance. This alone fixes most failures, with or without a service worker.
- Serve HTML with
Cache-Control: no-cacheso browsers and CDNs never pair an old HTML file with a partially new set of assets. Vite's documentation makes the same recommendation for the same reason. - Keep the previous precache in the worker for one generation when new workers can control old pages, as shown above.
- Precache every chunk a route can load, not just the entry chunk, when offline support matters. Workbox's build tools do this by default for everything matching
globPatterns. - Handle the failure in the page: when a dynamic import fails because the chunk is gone, the page is out of date. Activate the waiting worker (if any) and reload once.
The error surfaces differently per bundler and engine. Vite dispatches a vite:preloadError event on window, whose payload holds the original error; calling event.preventDefault() stops the error from being thrown. webpack rejects with an error named ChunkLoadError. A bare import() rejects with a TypeError whose message differs per engine.
const GUARD_KEY = "stale-chunk-reload-at";
function isStaleChunkError(error) {
const message = String(error?.message ?? "");
return (
error?.name === "ChunkLoadError" || // webpack
/Failed to fetch dynamically imported module/i.test(message) || // Chromium
/error loading dynamically imported module/i.test(message) || // Firefox
/Importing a module script failed/i.test(message) // Safari
);
}
async function recoverFromStaleChunk() {
try {
const last = Number(sessionStorage.getItem(GUARD_KEY));
if (Date.now() - last < 30_000) return false; // already tried: show an error UI instead
sessionStorage.setItem(GUARD_KEY, String(Date.now()));
} catch {
/* storage unavailable: still try once */
}
// If a new worker is waiting, activate it first, so the reload is served
// by the new version instead of the old worker's precache.
const registration = await navigator.serviceWorker?.getRegistration();
if (registration?.waiting && navigator.serviceWorker.controller) {
const switched = new Promise((resolve) =>
navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true }),
);
registration.waiting.postMessage({ type: "SKIP_WAITING" });
await Promise.race([switched, new Promise((r) => setTimeout(r, 3000))]);
}
window.location.reload();
return true;
}
// Vite: fired for failed dynamic imports and preloads.
window.addEventListener("vite:preloadError", (event) => {
event.preventDefault(); // don't also throw
recoverFromStaleChunk();
});
// Everything else: unhandled rejections from import() or ChunkLoadError.
window.addEventListener("unhandledrejection", (event) => {
if (isStaleChunkError(event.reason)) {
event.preventDefault();
recoverFromStaleChunk();
}
});
Route-level code (a router's lazy-route loader, a framework's error boundary) should call recoverFromStaleChunk() from its own error handling as well, because errors caught there never become unhandled rejections. See SPA vs MPA PWAs for the architectural side of this problem.
Emergency procedures¶
Sooner or later a service worker ships with a bug that breaks navigation, serves a blank page from cache, or loops. The platform guarantees one thing that makes every such bug recoverable: the update request for the worker script is never routed through a service worker (its service-workers mode is none). As long as users navigate into scope, the browser fetches /sw.js straight from your server, no matter how broken the running worker is.
That guarantee comes with conditions you must preserve:
- The worker's URL must not change. Browsers only ever check the URL stored in the registration. A fix deployed at
/sw-v2.jsis invisible to users whose pages (possibly served from the broken worker's cache) keep registering/sw.js. - The URL must keep returning valid JavaScript. Deleting
sw.jsdoes not unregister anything: a 404 during an update check is a failed check, and the broken worker keeps running indefinitely. An SPA rewrite that answers withindex.htmlis just as bad. - Navigations must keep happening. A soft update runs after navigations. A page that never navigates will pick up the fix only after its registration goes stale and a subresource request triggers a check, or when you call
update().
Kill-switch service worker¶
A kill-switch worker is a minimal script deployed at the same URL as the broken one. It installs, activates immediately, removes what the old worker left behind, unregisters itself, and reloads open pages so they come straight from the network.
// Emergency kill switch. Deploy at the SAME URL as the broken worker.
// Its bytes differ from the broken version, so every user's next update
// check installs it.
self.addEventListener("install", () => {
// Don't wait for tabs to close: we want the broken worker gone now.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// 1. Remove caches the broken worker created (all of them, or filter
// by your app's prefix on shared origins).
const names = await caches.keys();
await Promise.all(names.map((name) => caches.delete(name)));
// 2. Unregister. Future navigations match no registration and go to
// the network. Pages already open stay controlled by this worker
// until they unload, which is fine: it has no fetch handler.
await self.registration.unregister();
// 3. Reload the windows this worker now controls. After activation
// they are all clients of this worker, so navigate() is allowed.
const windows = await self.clients.matchAll({ type: "window" });
await Promise.all(
windows.map((client) =>
client.navigate(client.url).catch(() => {
// Uncontrolled or cross-origin clients reject; ignore them.
}),
),
);
})(),
);
});
// Deliberately no fetch listener: requests bypass this worker entirely.
// Keeps the registration (and its push subscription) but stops the worker
// from touching any request. Use this when you still need push, or plan
// to ship a fixed worker at the same URL soon.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(names.map((name) => caches.delete(name)));
await self.clients.claim();
})(),
);
});
// No fetch listener: the browser skips this worker for fetches. Keep any
// push/notificationclick handlers you still need below.
sequenceDiagram
participant User
participant Broken as Broken worker (active)
participant Server
participant Kill as Kill-switch worker
User->>Broken: Navigates (broken worker serves the page from cache)
Note over Broken,Server: The script request bypasses every service worker
Server-->>Kill: GET /sw.js returns kill-switch bytes
Kill->>Kill: install, skipWaiting()
Kill->>Broken: Activate, broken worker becomes redundant
Kill->>Kill: delete caches, unregister()
Kill->>User: client.navigate(url) reloads open windows
User->>Server: Page loads from the network, no worker Notes that matter when you actually need this:
WindowClient.navigate()only works on clients controlled by the calling worker. After askipWaiting()activation, every client that used the registration is switched to the new worker, so they qualify. MDN's compatibility data marks Safari'snavigate()as supported from Safari 16; in earlier versions the method exists but always fails withNotSupportedError. The caches are already gone and the registration is already removed by then, so the kill switch still works there; those tabs simply stay on the broken page until the user reloads.- Reloading windows loses in-memory state. If the broken version still lets users type, consider posting a message to clients asking them to show a "Please reload" banner instead of forcing navigation.
- Keep the kill switch deployed for a while. Users who haven't visited since the bad release still have the broken worker, and they only pick up the kill switch on their next visit. Their first page view after coming back may still be served by the broken worker before the update takes over.
- Unregistering deactivates the registration's push subscriptions. If push matters, use the pass-through variant.
- Treat the kill switch as a pre-written, pre-tested file in your repository, not something you write during an incident.
Rolling back a bad deploy¶
Service workers only move forward: the browser installs whatever is at the URL now if its bytes differ from the newest worker. That means a rollback is just a redeploy of the previous build, and it works:
| User's state | What the rollback does |
|---|---|
| Still on the good version (v1) | Fetched bytes equal v1: no update. Nothing happens. |
| On the bad version (v2), active | Bytes differ from v2: v1's code installs as a new worker and follows your normal activation pattern. |
| v2 installed but waiting | Bytes differ from the waiting v2: v1's code installs, the waiting v2 becomes redundant. |
flowchart TD
A["Bad v2 detected"] --> B{"Did v2 change IndexedDB versions or message formats?"}
B -- no --> C["Redeploy v1 build at the same /sw.js URL"]
B -- yes --> D["Build v3 = v1 code + v2 schema version and migrations"]
D --> E["Deploy v3 at the same /sw.js URL"]
C --> F{"Is v2 actively breaking pages?"}
E --> F
F -- yes --> G["Enable skipWaiting() for this release only"]
F -- no --> H["Keep the normal activation pattern"]
G --> I["Keep v1 and v2 hashed assets on the server"]
H --> I
I --> J["Watch Service-Worker: script requests and client build IDs converge"] What does not roll back automatically:
- Data migrations. If v2 upgraded an IndexedDB database to a higher version, the rolled-back code opening it with the lower version gets a
VersionError. Plan migrations to be forward-compatible, and ship rollbacks as "v1 code with v2's schema version and migration", not a literal old build. - Caches created by v2. They are deleted only if the rolled-back code's
activatecleanup recognizes them as old. The prefix-based cleanup above handles this; an allowlist of names from v1 does not know v2's names, so it deletes them, which is also fine. - The activation pattern. If v2 is waiting (because you use prompts) and the user never accepts, they may still be on v1 anyway. If v2 is active and your pattern is "default wait", the fixed version waits too. For a truly broken v2, ship the rollback with
skipWaiting()enabled for that one release. - Assets. Redeploy the old build's hashed assets, and do not delete v2's: pages running v2 still need them until they reload.
Clear-Site-Data¶
The Clear-Site-Data response header asks the browser to wipe data for the response's origin. The directives relevant to service workers:
| Directive | Clears | Notes |
|---|---|---|
"storage" | localStorage, sessionStorage, IndexedDB, service worker registrations (each is unregistered), Cache Storage and other script-accessible storage | Supported in Chrome 61+, Firefox 63+ and Safari 17+ according to MDN. |
"cache" | The HTTP cache for the origin, and depending on the browser also back/forward cache, prerenders and similar | Partial in Chromium: MDN notes some requests may still come from the cache until reload, and that the directive "may cause seconds-long hangs". |
"cookies" | Cookies for the registrable domain, including subdomains | Logs the user out everywhere on that domain. |
"executionContexts" | Reloads browsing contexts for the origin | Never shipped in Chromium; Firefox supported it in 63 to 67 and Safari in 17 to 18.2, both then removed it. Don't rely on it. |
"*" | Everything above | Partial in Chromium, as for "cache". |
The values must be quoted strings. Two rules from the Clear-Site-Data specification decide where the header works:
- It is ignored on responses served by a service worker. Otherwise a worker could fabricate responses that wipe data for any origin. If your broken worker intercepts navigations, sending the header on your HTML does nothing for users stuck behind it.
- It is honored on the service worker update response, because that is a network response. The spec's own "kill switch" example suggests sending it when the roughly daily update check arrives. In practice, sending
Clear-Site-Data: "storage"on/sw.jsmakes every update check that hits the server wipe the origin's storage and unregister its workers.
location = /sw.js {
# Every update check that reaches the origin wipes storage and unregisters
# workers. Remove this block once the incident is over, or every check
# will keep deleting user data.
add_header Clear-Site-Data '"storage"' always;
add_header Cache-Control "no-cache" always;
types { text/javascript js; }
}
Clear-Site-Data deletes user data
"storage" removes IndexedDB and localStorage too: offline drafts, queued background-sync requests, saved preferences. Prefer a kill-switch worker, which can delete exactly the caches you choose and leave user data alone. Use Clear-Site-Data when the worker is so broken that even a kill switch is not an option (for example, when the worker's own URL now has to serve something else), and verify in every browser you support that it behaves as the spec describes before you depend on it in an incident.
A common legitimate use is sign-out: Clear-Site-Data: "cache", "cookies", "storage" on the logout response removes cached private data, including anything a worker cached for the signed-in user. The response must not be intercepted by the worker, so either exclude the logout URL from your fetch handler or make it a navigation your worker passes to the network untouched.
Testing updates¶
Update bugs only show up across two deploys, so test them deliberately.
Chrome and Edge DevTools¶
The Application panel's Service workers pane has the controls you need:
- Update on reload: each navigation refetches the worker, installs it as a new version even if it is byte-identical, skips the waiting phase, and then navigates. Useful while developing the worker itself, but it hides every waiting-related bug, and it triggers
controllerchangeon each reload, which is exactly what exposes unguarded reload handlers. Test your update flow with it off. - skipWaiting: a link next to a waiting worker that activates it, the same as calling
self.skipWaiting()inside it. - Update: runs a one-time update check.
- Unregister: removes the registration.
- Offline and Bypass for network: simulate no connectivity, or send requests to the network without passing through the worker.
- See all registrations opens
chrome://serviceworker-internals, where you can inspect, start, stop and unregister workers across the profile.
The Storage section's Clear site data button removes registrations, caches and IndexedDB for the origin in one go. See Browser DevTools for a full walkthrough.
Firefox and Safari¶
In Firefox, about:debugging#/runtime/this-firefox lists registered workers and lets you start, inspect and unregister them, and DevTools has an Application → Service Workers panel. In Safari, enable the Develop menu and use Develop → Service Workers to attach Web Inspector to a specific worker; clearing website data is under Safari's privacy settings. In both, test update flows the realistic way: deploy two builds and navigate between them.
A manual update test plan¶
Run this once per significant change to your update logic, in every engine you support:
- Deploy v1. Visit, confirm the page is controlled and works offline.
- Open a second tab on the same app.
- Deploy v2 (change the worker's bytes and at least one lazy chunk).
- Navigate in tab 1: confirm v2 installs and waits, and the prompt appears.
- In tab 1, trigger a lazy route that exists only in v1: confirm it still loads.
- Accept the prompt in tab 1: confirm exactly one reload and that tab 1 now runs v2.
- Look at tab 2: confirm it shows the stale-tab banner and does not reload by itself.
- Reload tab 1 several times: confirm no reload loop, no repeated prompt.
- Deploy v3 while v2 is waiting in another browser profile: confirm v2 is replaced by v3.
- Go offline and trigger a periodic check: confirm a quiet failure, no error UI.
- Temporarily serve the kill switch: confirm open tabs reload uncontrolled and caches are gone.
Automating update tests¶
Browser automation can drive the same flow by serving two builds from a test server and switching between them. The page-side calls are ordinary JavaScript, so they work in any engine your test runner supports:
import { test, expect } from "@playwright/test";
test("a new version prompts and reloads once", async ({ page }) => {
await page.goto("http://localhost:4173/?build=v1");
await page.evaluate(async () => {
await navigator.serviceWorker.ready;
});
await page.reload(); // now controlled by v1
expect(await page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true);
// Your test server switches /sw.js to v2's bytes here.
await fetch("http://localhost:4173/__test/switch-build?to=v2");
await page.evaluate(async () => {
const reg = await navigator.serviceWorker.getRegistration();
await reg.update();
});
const toast = page.getByRole("status").filter({ hasText: "new version" });
await expect(toast).toBeVisible();
const navigation = page.waitForEvent("framenavigated");
await toast.getByRole("button", { name: "Reload" }).click();
await navigation;
const build = await page.evaluate(() => window.__BUILD_ID__);
expect(build).toBe("v2");
});
More on test harnesses, including service-worker-aware fixtures, is on Automated Testing.
Browser support¶
| Feature | Chrome / Edge | Firefox | Safari (macOS / iOS) |
|---|---|---|---|
registration.update() | ✅ 45 / 17 | ✅ 44 | ✅ 11.1 / 11.3 |
updatefound event | ✅ 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 |
updateViaCache | ✅ 68 / 18 | ✅ 57 | ✅ 11.1 / 11.3 |
Byte comparison of importScripts() scripts | ✅ 78 / 79 | ✅ 56 | ✅ |
WindowClient.navigate() | ✅ 49 / 17 | ✅ 50 | ✅ 16 ⚠️ |
| Module service workers | ✅ 91 / 91 | ✅ 147 | ✅ 15 / 15 |
Clear-Site-Data: "storage" | ✅ 61 / 79 | ✅ 63 | ✅ 17 / 17 |
Clear-Site-Data: "cache" | ⚠️ partial since 61 / 79 | ✅ 138 ⚠️ | ✅ 17 / 17 |
Support data as of September 2026. Edge versions below 79 refer to the pre-Chromium EdgeHTML engine. ⚠️ Before Safari 16, WindowClient.navigate() existed but always failed with NotSupportedError. ⚠️ MDN marks Chromium's "cache" directive as partial (some requests may still be served from cache until a reload). Firefox supported "cache" in versions 63 to 93 and again from 138. The imported-scripts row for Chrome and Firefox comes from Chrome's "Fresher service workers" article, which also states Safari already behaved this way. For live data see MDN's ServiceWorkerRegistration compatibility table and caniuse: Service Workers.
Common pitfalls¶
- Changing the worker's URL per release (
sw.v42.js). Old cached pages keep registering the old URL; users never find the new one. Keep one stable URL. - Deleting
sw.jsto "turn off" the worker. A 404 is a failed update check, not an unregistration. Deploy a kill switch instead. - Caching
sw.jsat the CDN with a long TTL, so revalidations return the old file. Serveno-cacheand purge on deploy. - Relying on
updateViaCachedefaults for stable-URL imports. With"imports", a cacheable/sw-lib.jsis compared against its HTTP-cached copy, so changes can go unnoticed for as long as itsmax-ageallows. Hash the import, serve it withno-cache, or use"none". skipWaiting()with an aggressive precache cleanup, which strands open pages without their lazy chunks.- Reloading on every
controllerchange, which loops with DevTools "Update on reload" and reloads first-install pages that calledclients.claim(). - Long work in
activate, which blocks every fetch from every open page until it finishes. - Assuming
await registration.update()means "the new version is ready". It resolves when the check finishes or installation starts. Watchinstallingandstatechange. - Non-deterministic worker builds that produce new bytes on every deploy and force pointless reinstalls.
- Data migrations that can't be rolled back. Any IndexedDB version bump is permanent for that user.
More in Pitfalls & Anti-Patterns.
Debugging update problems¶
When users report "I still see the old version", work through these questions in order:
- Is the worker's bytes actually different? Fetch
/sw.jswithcurlfrom outside your CDN and compare with the previous deploy's file. - Is the update check reaching your server? Look for requests with the
Service-Worker: scriptheader in your logs. None at all means no navigations, or a CDN answering from its cache. - Does the update check succeed? A 404, a redirect or a
text/htmlresponse fails silently in soft updates. Callregistration.update()in the console to see the error. - Is the new worker waiting? In DevTools, a second worker listed as "waiting to activate" means the update was found, and your activation pattern is the problem, not the update check.
- Is the page reading stale HTML? If the worker serves HTML cache-first, users get the old shell until the new worker activates. That's expected with the default wait.
A console snippet that summarizes the state of the registration for the current page:
const reg = await navigator.serviceWorker.getRegistration();
({
scope: reg?.scope,
updateViaCache: reg?.updateViaCache,
controller: navigator.serviceWorker.controller?.scriptURL ?? null,
installing: reg?.installing?.state ?? null,
waiting: reg?.waiting?.state ?? null,
active: reg?.active?.state ?? null,
});
// Force a check and report what happened:
await reg.update().then(
() => console.log(reg.installing ? "Update found, installing" : "No update"),
(e) => console.error("Update check failed:", e.name, e.message),
);
Further reading¶
On this site
- Service Worker Lifecycle: installing, waiting, activating, and
clients.claim(). - Registration & Scope: serving requirements that every update check re-validates.
- Precaching & Runtime Caching: revisioned precaches and cleanup.
- HTTP Caching & Service Workers: how browser and CDN caches interact with workers.
- Workbox Fundamentals and Vite PWA Plugin: update flows built into the tooling.
- Browser DevTools: inspecting and forcing updates.
- Production Checklist: update-related items to verify before launch.
External references
- Service Workers specification: Update, Soft Update, Install, Try Activate and Activate algorithms.
- web.dev: The service worker lifecycle
- Chrome for Developers: Fresher service workers, by default
- Chrome for Developers: Handling service worker updates with immediacy
- Chrome for Developers: workbox-window
- MDN: ServiceWorkerRegistration.update()
- MDN: Clear-Site-Data and the Clear Site Data specification
- Vite: Load error handling