Skip to content

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 push and sync when the last check is more than 24 hours old, and whenever you call registration.update(). A plain register() 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.
  • updateViaCache defaults 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:

sw.js (top of file, generated at build time)
// 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 visibilitychange listener 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:

  1. Fetch the main script with the destination serviceworker, the header Service-Worker: script, service-workers mode none (the request is never intercepted by any worker), and redirect mode error.
  2. Choose the cache mode. The request uses the Fetch cache mode no-cache (always revalidate with the server) if the registration's updateViaCache is not "all", if the job forces a cache bypass (DevTools), or if the registration is stale. Otherwise the HTTP cache may answer.
  3. 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, taking Service-Worker-Allowed into account (otherwise SecurityError).
  4. Record the check. If the response did not come from the local HTTP cache (a network response or a 304 revalidation both count), the registration's last update check time is set to now. This timestamp drives the 24-hour staleness rule.
  5. 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.
  6. 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.
  7. If nothing changed, set the registration's updateViaCache to the job's mode, resolve the job's promise with the registration, and stop.
  8. 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.
  9. Install: the new worker becomes registration.installing, the job's promise resolves, updatefound fires on every ServiceWorkerRegistration object for this registration in every same-origin page, and the install event 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.js from 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 pin sw.js to 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 parsed or installing is 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 a fetch handler) fails with a NetworkError.
  • 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 long max-age can then be answered from the HTTP cache for as long as its own headers allow, and a change to it goes unnoticed.
  • no-cache is 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 a 304 Not Modified. The Fetch standard also adds Cache-Control: max-age=0 to such requests. A 304 still 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:

main.js
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:

  1. The old active worker is terminated and becomes redundant.
  2. The waiting worker becomes the active worker, in the activating state.
  3. Pending navigator.serviceWorker.ready promises in matching clients resolve.
  4. Every client that was using the registration switches to the new worker, and each receives controllerchange. This happens whether or not the new worker calls clients.claim(). claim() is only about clients that were not controlled at all.
  5. The activate event is dispatched. While the worker is activating, incoming fetch and functional events wait until it reaches activated, so a slow activate handler 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 install rejects (a precache request failed), the new worker becomes redundant and 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.waiting is already set;
  • the update is in progress when the page registers, so registration.installing is set;
  • the update is found later (by this page's navigation check, a periodic update(), or another tab), so updatefound fires.
when-waiting.js
/**
 * 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:

sw.js
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:

sw.js
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.

sw-update.js
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.
      },
    });
  });
}
sw-update.js
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 for load unless you pass { immediate: true }.
  • The waiting event is dispatched about 200 ms after the worker reaches installed, and only if it is still waiting, which filters out workers that called skipWaiting() during install.
  • Only the first updatefound after wb.register() counts as "this page's" worker, and only if it fires within 60 seconds of registration and for the same script URL. Any later updatefound, one for a different script URL, or one after the 60-second window is treated as external, and the resulting events carry isExternal: true. In practice, every update found by a periodic wb.update() in a long-lived tab is external.
  • The controlling event fires on every controllerchange; its isExternal is true whenever the new controller is not the worker this instance registered.
  • messageSkipWaiting() sends { type: "SKIP_WAITING" } to registration.waiting, and does nothing if no worker is waiting.
  • Build-generated Workbox workers (generateSW with skipWaiting: 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:

update-toast.js
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 controllerchange too, and they are now old pages talking to a new worker. The code above calls onStaleTab() 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
router-update-hook.js
/**
 * 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:

kiosk-auto-update.js
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:

periodic-update-checks.js
/**
 * 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 visibilitychange trigger covers the moment the user returns, which is when an update matters.
  • Checks from inside the worker (calling self.registration.update() from a message or periodicsync handler) 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, calling update() only when that returns 200, 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.

sw.js
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:

sw.js
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 versionchange on every other open connection (old tabs, the old worker). Until they close, the new open request receives blocked and the upgradeneeded migration 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.
db.js (shared by pages and the worker)
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:

sw.js
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;
  }
});
version-check.js (page)
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:

  1. 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.
  2. Serve HTML with Cache-Control: no-cache so 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.
  3. Keep the previous precache in the worker for one generation when new workers can control old pages, as shown above.
  4. 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.
  5. 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.

stale-chunk-recovery.js
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.js is 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.js does 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 with index.html is 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.

sw.js (kill switch)
// 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.
sw.js (neutralized)
// 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 a skipWaiting() activation, every client that used the registration is switched to the new worker, so they qualify. MDN's compatibility data marks Safari's navigate() as supported from Safari 16; in earlier versions the method exists but always fails with NotSupportedError. 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 activate cleanup 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.js makes every update check that hits the server wipe the origin's storage and unregister its workers.
nginx.conf (temporary emergency config)
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 controllerchange on 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:

update.spec.js (Playwright)
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.js to "turn off" the worker. A 404 is a failed update check, not an unregistration. Deploy a kill switch instead.
  • Caching sw.js at the CDN with a long TTL, so revalidations return the old file. Serve no-cache and purge on deploy.
  • Relying on updateViaCache defaults for stable-URL imports. With "imports", a cacheable /sw-lib.js is compared against its HTTP-cached copy, so changes can go unnoticed for as long as its max-age allows. Hash the import, serve it with no-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 called clients.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. Watch installing and statechange.
  • 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:

  1. Is the worker's bytes actually different? Fetch /sw.js with curl from outside your CDN and compare with the previous deploy's file.
  2. Is the update check reaching your server? Look for requests with the Service-Worker: script header in your logs. None at all means no navigations, or a CDN answering from its cache.
  3. Does the update check succeed? A 404, a redirect or a text/html response fails silently in soft updates. Call registration.update() in the console to see the error.
  4. 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.
  5. 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:

console
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

External references