Skip to content

Service Worker Update Patterns Compared

When you deploy a new service worker, the browser installs it next to the old one and then has to decide when the new version takes over the pages that are already open. There are five established ways to handle that moment: wait for every tab to close, activate immediately with skipWaiting(), ask the user to reload, switch at the next in-app navigation, or activate and reload automatically. Each trades update speed against the risk of running old page code against a new worker. This post compares all five, puts them through the same deploy scenarios, and ends with a decision table and a hybrid policy that lets each release choose its own pattern. The step-by-step implementation of each pattern lives on Updating Service Workers.

Key takeaways

  • Every pattern is a different answer to one question: how long may an old page run against a new worker and its caches? That window is the version skew each pattern accepts.
  • The default wait has zero skew but can leave users on an old version for days, because a reload doesn't activate a waiting worker and installed apps rarely close all their windows.
  • skipWaiting() on its own is only safe for workers that don't precache versioned code, or that keep serving the previous version's assets until old pages are gone.
  • The prompt and next-navigation patterns are the right default for app shells. The Navigation API, now in Chrome 102, Firefox 147 and Safari 26.2, lets you apply an update at the next route change without touching your router.
  • Auto-reload belongs on screens without user input, and always needs a loop guard.
  • You don't have to pick one pattern forever. Let each release declare its urgency and minimum compatible page build, and let the page choose the pattern at update time.

What an update pattern decides

The mechanics are covered in depth on Updating Service Workers and Lifecycle. The short version, which is all you need for this comparison:

  1. The browser checks for a new worker on navigations into scope, on functional events and subresource fetches once the registration is more than 24 hours stale, and whenever you call registration.update(). Since Chrome 68, the check for the main script bypasses the HTTP cache by default (updateViaCache: 'imports').
  2. If the bytes differ, the new worker installs. If install succeeds, it moves to waiting.
  3. It stays waiting until no client uses the registration anymore, or until something calls skipWaiting(). When it activates, every page the old worker controlled switches to it and gets controllerchange, whether or not the worker calls clients.claim(). (clients.claim() only matters for in-scope pages that had no controller at all.)

Step 3 is where the patterns differ. Three things have to agree for a page to work: the HTML and JavaScript already running in the tab, the active worker, and the caches that worker serves from. After a deploy, the running page belongs to version N and everything on the server belongs to N+1. A pattern decides when the worker moves to N+1 and when, or whether, the page follows.

flowchart LR
    subgraph Tab["Open tab"]
        P["Page code<br/>version N"]
    end
    subgraph Browser
        W["Active worker<br/>N or N+1?"]
        C["Caches<br/>N, N+1 or both?"]
    end
    S["Server<br/>version N+1"]
    P -- "fetch, import(), postMessage" --> W
    W --> C
    W --> S

If the worker moves to N+1 while the page stays on N, the page is running with version skew. Lazy-loaded chunks for version N may be missing from N+1's precache and gone from the server. The N+1 worker may reply in a message format the N page doesn't understand, or migrate an IndexedDB schema under it. Every pattern either prevents skew, bounds it, or accepts it and relies on you to make the versions compatible.

The five patterns at a glance

# Pattern Who triggers activation When the page moves to N+1 Skew window
1 Default wait The browser, when the last client is gone Next visit after all tabs close, or after a browser restart None
2 skipWaiting() immediately The new worker, in install Only when the user reloads or navigates From activation until the next full load, which can be hours
3 Prompt to reload The user, by accepting a prompt Right after they accept From activation to the reload: about a second
4 Activate on next navigation The app, at the next route change At that route change, with a full load From activation to the load: about a second
5 skipWaiting() plus auto-reload The new worker; the page reloads itself Right after activation, without asking About a second, but the reload interrupts the user

The criteria that matter when you choose:

  • Time to adoption: how long until most users run N+1. It matters for bug fixes and security fixes.
  • Skew exposure: how long, and in how many tabs, old code runs against the new worker.
  • Interruption: whether the user sees a reload they didn't ask for.
  • Data-loss risk: whether unsaved input can be lost.
  • Multi-tab behavior: what happens in the tabs the user isn't looking at.
  • Implementation cost: code, tests and UI.

Patterns 1, 2, 3 and 5 in brief

Updating Service Workers has the full, commented implementation of every pattern. What matters for the comparison:

Pattern 1: the default wait. You write no update code. The worker installs, waits, and activates when every window in scope has closed, or when the browser restarts and promotes it. It's safe, since a page always runs against the worker that served it and activate can delete old caches freely. It's also slow: a reload doesn't activate a waiting worker (why), mobile operating systems suspend installed PWAs rather than closing them, and installed desktop apps are windows users leave open. Use it when the worker doesn't precache versioned application code (network-first content sites, push-only or offline-page workers), and add periodic update checks.

Pattern 2: skipWaiting() immediately. The new worker calls skipWaiting() in install and activates as soon as it has installed. Open pages that the old worker controlled switch to the new worker when it activates, even without clients.claim(), but their code stays at version N until the next full load. This is the pattern with the most skew: if N's lazy chunks are gone from both the new precache and the server, lazy routes fail. You can make it safe by keeping the previous precache and falling back across caches, and by keeping message formats and schemas backward compatible. Use it when the worker has no versioned precache, or you have done that compatibility work. See Lifecycle and Pitfalls & Anti-Patterns for the failure mode.

Pattern 3: prompt the user to reload. The new worker waits. The page detects it (including a worker that was already waiting before the page loaded), shows a non-blocking prompt, and on accept posts SKIP_WAITING; the worker's message handler calls self.skipWaiting(), and the accepting tab reloads once on controllerchange, behind a refreshing flag. The page side is where the work is; the full vanilla and workbox-window implementation is Pattern 3 on the Updating page, and the hybrid policy later in this post contains a compact version. Three things the comparison adds: a toast users ignore behaves like Pattern 1, so re-show it and combine it with Pattern 4; when one tab accepts, the other tabs now run version-N code against the N+1 worker, so show them a "this tab is out of date" banner instead of reloading them; and it's the only pattern that lets the user pick the moment. Use it when you precache an app shell with hashed assets and users hold state you can't restore automatically.

Pattern 5: skipWaiting() plus automatic reload. Pattern 2 in the worker, plus a guarded location.reload() on controllerchange in every tab. Adoption is as fast as it gets, but a reload in the middle of typing loses input, and a deploy that produces different worker bytes on every request reloads the page in a loop unless a guard stops it (see Guarding against controllerchange reload loops and Pattern 5 on the Updating page). Use it when nobody types into the page (kiosks, dashboards, signage), and as the emergency path for a critical fix, which is what the hybrid policy below uses it for.

Pattern 4 with the Navigation API

Users already expect a page change when they click a link. If a worker is waiting, turn the next in-app navigation into a full page load after activating the new worker: no prompt, no unexpected reload, and a skew window as short as Pattern 3's. The Updating page shows the router-hook version; the Navigation API makes it router-independent, because every navigation the page can intercept passes through one navigate event. The API is in Chrome and Edge 102, Firefox 147 and Safari 26.2 according to MDN's compatibility data. Where it's missing, the function below does nothing, and you fall back to Pattern 3's prompt.

update-on-navigate.js
/**
 * When a service worker is waiting, turns the next in-app navigation into a full
 * page load served by the new worker. Must be installed BEFORE your router adds
 * its own navigate listener, so stopImmediatePropagation() can keep the router
 * from intercepting the navigation.
 */
export function installUpdateOnNavigate(registration, { timeoutMs = 3000, onActivate } = {}) {
  if (!('navigation' in window) || !navigator.serviceWorker) return () => {};
  const container = navigator.serviceWorker;
  let fullLoadPending = false;

  async function onNavigate(event) {
    if (fullLoadPending) {
      // Our own location.assign(): keep the router from intercepting it,
      // so the browser performs a real cross-document navigation.
      event.stopImmediatePropagation(); // (1)!
      return;
    }
    const waiting = registration.waiting;
    if (!waiting || !container.controller) return;
    if (!event.canIntercept || !event.cancelable || event.hashChange) return;
    if (event.downloadRequest !== null || event.formData || event.navigationType === 'traverse') return; // (2)!

    event.preventDefault();
    event.stopImmediatePropagation();
    fullLoadPending = true;
    onActivate?.(); // lets other controllerchange listeners know a full load is coming

    const switched = new Promise((resolve) =>
      container.addEventListener('controllerchange', resolve, { once: true }));
    waiting.postMessage({ type: 'SKIP_WAITING' });
    // Activation waits for the old worker's in-flight events; don't hang the click.
    await Promise.race([switched, new Promise((r) => setTimeout(r, timeoutMs))]);
    location.assign(event.destination.url);
  }

  navigation.addEventListener('navigate', onNavigate);
  return () => navigation.removeEventListener('navigate', onNavigate);
}
  1. location.assign() fires a new navigate event synchronously. Without this branch, the router intercepts it and the "full load" becomes another same-document navigation.
  2. Downloads, form submissions and back/forward traversals are left alone. Canceling a traversal is only allowed in limited cases, and replaying a POST body with location.assign() would turn it into a GET.

Things to know before you ship it:

  • Registration order matters. Listeners run in the order they were added. Install the hook before your router's navigate listener. Routers that still use the History API and click handlers don't go through navigate for their own pushState() calls, only for the link click itself. Test your router.
  • Programmatic navigations reject. If your code calls navigation.navigate() and the hook cancels it, both the committed and finished promises reject with an AbortError. Catch them.
  • The full load costs what a first navigation costs. State that lives only in memory is lost unless you persist it. That's usually acceptable at a route change.

Five deploy scenarios

The same deploy, published at 10:00, looks very different depending on the user and the pattern. This table assumes the worker precaches a hashed app shell with lazy-loaded route chunks, and that the page checks for updates when it becomes visible.

Scenario 1. Wait 2. skipWaiting() 3. Prompt 4. Next navigation 5. Auto-reload
One tab open since yesterday, user active Stays on N until all tabs close Moves to N+1 worker at once; lazy routes can 404 unless old caches are kept Sees a prompt; on N+1 a second after accepting On N+1 at the next click, with a full load Page reloads at about 10:00
Three tabs open All stay on N All three get skew Accepting tab reloads; the other two show a stale banner Each tab moves at its own next navigation All three reload
Installed mobile app, suspended overnight Stays on N; resume isn't a navigation Moves to N+1 worker on the first check after resume Prompt after the first check after resume First navigation after resume Reloads right after resume
Offline at 10:00, back at 12:00 Update installs at the first check after 12:00, then waits Activates when the check succeeds Prompt after 12:00 After 12:00, at the next navigation Reload after 12:00
Filling in a long form Unaffected Unaffected until a lazy chunk or API call hits the skew Should delay the prompt while the form is dirty Unaffected; next route change is a full load Loses the input unless saved first

Two things stand out. Pattern 1's weakness is concentrated in installed apps, which are exactly the users you want on the latest version. Pattern 5's weakness is concentrated on your most engaged users, the ones in the middle of a task. Patterns 3 and 4 are the only ones with no bad row, which is why they are the usual default for app shells.

Decision table

If your app… Use Why
Has no versioned precache (network-first HTML and assets, push-only worker) 2 Nothing for old pages to lose; fastest adoption
Is a content site with a small offline fallback 1 or 2 Skew is harmless; keep it simple
Is an SPA with hashed, lazily loaded chunks 4, with 3 as fallback Short skew window, no interruption
Holds unsaved user state (editors, long forms, uploads) 3 The user picks the moment; save state before reloading
Runs unattended (kiosk, dashboard, signage) 5 No user to ask; guard the reload
Can't keep message formats or schemas backward compatible 3 or 4, never 2 Keep the skew window to about a second
Must be able to push a security fix within minutes Hybrid, with 5 for critical releases The release, not the codebase, decides the urgency
Is distributed through an app store wrapper or TWA Same as the web version The worker updates the same way inside a Trusted Web Activity

Letting each release choose its pattern

Most apps need different behavior for different releases. A copy change can wait for the next navigation. A fix for data corruption can't. A release that changes the page-to-worker message protocol must not coexist with old pages at all. Build that into the release instead of the code: stamp each worker with its release metadata, and let the page read it from the waiting worker before deciding what to do.

flowchart TD
    W["New worker installed and waiting"] --> Q["Page asks it: GET_RELEASE"]
    Q --> C{"urgency critical, or<br/>page build below minClientBuild?"}
    C -- yes --> S["Save state, SKIP_WAITING,<br/>reload now (pattern 5)"]
    C -- no --> U{"urgency silent?"}
    U -- yes --> N["Apply at next navigation (pattern 4)"]
    U -- no --> P["Prompt (pattern 3),<br/>and apply at next navigation (pattern 4)"]

The build stamps two values: a build number in the HTML (<html data-build="42">) and a RELEASE object in the worker. Use plain increasing integers, so comparisons never need a semver parser.

sw.js (excerpt)
// Stamped by the build. build: monotonically increasing integer.
// urgency: "silent" | "normal" | "critical"
// minClientBuild: oldest page build that can safely run against this worker.
const RELEASE = { build: 42, urgency: "normal", minClientBuild: 0 };

self.addEventListener("message", (event) => {
  switch (event.data?.type) {
    case "GET_RELEASE":
      // Reply on the transferred port so the page can await the answer.
      event.ports[0]?.postMessage(RELEASE);
      break;
    case "SKIP_WAITING":
      self.skipWaiting();
      break;
  }
});

A waiting worker can receive messages. Posting to registration.waiting starts it if it isn't running, and it can answer on a MessageChannel port. Messaging & the Clients API covers the details.

update-policy.js
import { installUpdateOnNavigate } from './update-on-navigate.js';

// Build number stamped into the HTML (<html data-build="42">) at build time.
const PAGE_BUILD = Number(document.documentElement.dataset.build ?? 0);
const RELOAD_KEY = 'sw-policy-reloaded-at';

/** Sends a message to a worker and waits for a reply on a MessageChannel. */
function askWorker(worker, message, timeoutMs = 3000) {
  return new Promise((resolve, reject) => {
    const { port1, port2 } = new MessageChannel();
    const timer = setTimeout(() => {
      port1.close();
      reject(new Error('worker did not answer'));
    }, timeoutMs);
    port1.onmessage = (event) => {
      clearTimeout(timer);
      port1.close();
      resolve(event.data);
    };
    worker.postMessage(message, [port2]);
  });
}

/** Calls back once per worker that reaches "installed" while a controller exists. */
function whenWaiting(registration, callback) {
  const seen = new WeakSet();
  const notify = (worker) => {
    if (worker && !seen.has(worker)) {
      seen.add(worker);
      callback(worker);
    }
  };
  // An update may already be waiting from an earlier visit.
  if (registration.waiting && navigator.serviceWorker.controller) notify(registration.waiting);
  registration.addEventListener('updatefound', () => {
    const installing = registration.installing;
    installing?.addEventListener('statechange', () => {
      // No controller means first install, which is not an update.
      if (installing.state === 'installed' && navigator.serviceWorker.controller) notify(installing);
    });
  });
}

function reloadedRecently(windowMs = 10_000) {
  try {
    const at = Number(sessionStorage.getItem(RELOAD_KEY));
    return Number.isFinite(at) && Date.now() - at < windowMs;
  } catch {
    return false;
  }
}

/**
 * @param {ServiceWorkerRegistration} registration
 * @param {{
 *   prompt(api: { release: object, accept(): void, dismiss(): void }): void,
 *   saveState?(): Promise<void>,
 *   onStaleTab?(): void,
 * }} ui
 */
export function initUpdatePolicy(registration, ui) {
  const container = navigator.serviceWorker;
  // Track the previous controller: going from none to one is clients.claim()
  // on first install, not an update, even if it happens long after page load.
  let lastController = container.controller;
  let acceptedHere = false;
  let reloading = false;

  // Silent and normal releases also apply at the next in-app navigation,
  // which performs its own full load: suppress the stale-tab UI for it.
  installUpdateOnNavigate(registration, { onActivate: () => { reloading = true; } });

  container.addEventListener('controllerchange', () => {
    const previous = lastController;
    lastController = container.controller;
    if (!previous || reloading) return;
    if (!acceptedHere) {
      ui.onStaleTab?.(); // another tab activated the update: don't reload under the user
      return;
    }
    if (reloadedRecently()) return; // loop breaker
    reloading = true;
    try {
      sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
    } catch {
      /* ignore */
    }
    location.reload();
  });

  function activate(worker) {
    acceptedHere = true;
    if (registration.waiting !== worker) {
      // Already activated by another tab (or replaced by a newer build).
      location.reload();
      return;
    }
    worker.postMessage({ type: 'SKIP_WAITING' });
  }

  whenWaiting(registration, async (worker) => {
    let release = { build: 0, urgency: 'normal', minClientBuild: 0 };
    try {
      release = await askWorker(worker, { type: 'GET_RELEASE' });
    } catch {
      // Older workers don't answer: treat the update as a normal one.
    }

    const pageTooOld = PAGE_BUILD < release.minClientBuild;
    if (release.urgency === 'critical' || pageTooOld) {
      // This page must not keep running against the new worker's caches or APIs.
      await ui.saveState?.();
      activate(worker);
    } else if (release.urgency === 'silent') {
      // Nothing to show: installUpdateOnNavigate() applies it on the next route change.
    } else {
      ui.prompt({ release, accept: () => activate(worker), dismiss() {} });
    }
  });
}

Wire it up once, after registration:

main.js
import { initUpdatePolicy } from "./update-policy.js";
import { showUpdateToast, showStaleBanner } from "./update-ui.js";
import { saveDraftsToIndexedDB } from "./drafts.js";
import { startRouter } from "./router.js";

if ("serviceWorker" in navigator) {
  const registration = await navigator.serviceWorker.register("/sw.js");
  // Install before the router starts, so the navigate hook runs first.
  initUpdatePolicy(registration, {
    prompt: showUpdateToast,
    saveState: saveDraftsToIndexedDB,
    onStaleTab: showStaleBanner,
  });
  startRouter();
}

The loop breaker has a cost: its 10-second window also blocks legitimate back-to-back reloads, so two critical releases deployed within ten seconds of each other apply the second one at the next navigation instead. That's the right trade-off for a loop breaker, but know that it's there.

Two operating rules make the policy trustworthy:

  • Set minClientBuild whenever the page-to-worker contract changes. New message types, renamed cache names the page reads, and IndexedDB schema changes the worker applies all qualify. Updating Service Workers describes how to version the protocol.
  • Reserve critical for fixes that justify interrupting users. If every release is critical, you have built Pattern 5 with extra steps.

How the patterns map to Workbox and the Vite PWA plugin

Tool setting Pattern Notes
Workbox generateSW with skipWaiting: false (default) 1, or 3 with workbox-window The generated worker includes the SKIP_WAITING message listener
Workbox generateSW with skipWaiting: true, clientsClaim: true 2 Add the cache fallback yourself if you lazy-load chunks
workbox-window: waiting event plus messageSkipWaiting() 3 controlling fires on every controllerchange; reload only in the tab that accepted
vite-plugin-pwa registerType: "prompt" (default) 3 onNeedRefresh() shows your UI; updateServiceWorker() sends SKIP_WAITING
vite-plugin-pwa registerType: "autoUpdate" 2 plus reload, close to 5 Forces skipWaiting and clientsClaim only when injectRegister is auto or null; onNeedReload (1.3.0 and later) lets you defer the reload

Changing an installed base from an automatic pattern to a prompt is harder than the reverse. The old worker keeps calling skipWaiting() until it has been replaced, and old pages have no prompt UI. Start with the conservative pattern. Workbox Fundamentals and Vite PWA Plugin cover the configuration in detail.

Measuring adoption

You can't pick a pattern well without knowing how long your users actually stay on old versions. Report both the page build and the controlling worker's build with your analytics, and chart the share of sessions on the latest build over the days after each deploy.

version-telemetry.js
// Check navigator.standalone first: iOS web apps whose manifest says "standalone"
// match display-mode: fullscreen (WebKit bug 264218), and macOS Safari Dock apps
// also set it. Firefox taskbar apps match minimal-ui, so check all app-like modes.
// Chromium also matches fullscreen for a browser tab in F11 mode, a small overcount.
function installedDisplayMode() {
  if (navigator.standalone === true) return "standalone";
  for (const mode of ["window-controls-overlay", "standalone", "fullscreen", "minimal-ui"]) {
    if (matchMedia(`(display-mode: ${mode})`).matches) return mode;
  }
  return null;
}

// Reports which page build and which worker build served this page view.
// A mismatch means the page is running with version skew.
export async function reportVersions(sendEvent) {
  const pageBuild = Number(document.documentElement.dataset.build ?? 0);
  const controller = navigator.serviceWorker?.controller;
  let workerBuild = null;

  if (controller) {
    workerBuild = await new Promise((resolve) => {
      const { port1, port2 } = new MessageChannel();
      const timer = setTimeout(() => resolve(null), 2000); // old workers may not answer
      port1.onmessage = (event) => {
        clearTimeout(timer);
        resolve(event.data?.build ?? null);
      };
      controller.postMessage({ type: "GET_RELEASE" }, [port2]);
    });
  }

  sendEvent("app_version", {
    page_build: pageBuild,
    worker_build: workerBuild,
    skew: workerBuild !== null && workerBuild !== pageBuild,
    display_mode: installedDisplayMode() ?? "browser",
  });
}

Split the adoption curve by display mode. If installed users lag far behind browser users, your pattern relies on tab closes that installed apps rarely do, and you need Pattern 3 or 4 plus update checks on visibilitychange. A nonzero skew rate on a pattern that shouldn't have any points to a worker calling skipWaiting() that you didn't expect. Analytics for PWAs shows how to send these events reliably, including from installed apps that are often offline.

Browser support for the building blocks

Patterns 1, 2, 3 and 5 only use service worker APIs that every engine has shipped since 2018. Pattern 4 and the hybrid policy's silent path depend on the Navigation API, which reached all three engines only in January 2026.

API used Used by Chrome / Edge Firefox Safari (macOS / iOS)
skipWaiting() 2, 3, 4, 5 ✅ 41 ✅ 44 ✅ 11.1 / 11.3
clients.claim() 2, 5 ✅ 42 ✅ 44 ✅ 11.1 / 11.3
registration.update() Periodic checks in all patterns ✅ 45 ✅ 44 ✅ 11.1 / 11.3
registration.waiting, updatefound, controllerchange 3, 4, 5, hybrid ✅ 40 ✅ 44 ✅ 11.1 / 11.3
postMessage() to a waiting worker with a MessageChannel port 3, 4, hybrid ✅ 40 ✅ 44 ✅ 11.1 / 11.3
updateViaCache (default "imports") Update checks ✅ 68 ✅ 57 ✅ 11.1 / 11.3
Navigation API navigate event 4, hybrid ✅ 102 ✅ 147 ✅ 26.2
NavigateEvent.canIntercept 4, hybrid ✅ 105 ✅ 147 ✅ 26.2

Support data as of September 2026. Check MDN's Navigation API compatibility data and caniuse for live data.

Two practical consequences. First, Chrome 102 to 104 expose navigation but not canIntercept; installUpdateOnNavigate() reads !event.canIntercept as "can't intercept" and does nothing there, which is the safe outcome. Second, Firefox ESR releases before 147 and Safari before 26.2 still have a meaningful share in some enterprise and older-iOS audiences. On those, the hybrid policy's silent releases wait like Pattern 1 until the next full load, so keep a periodic registration.update() and consider treating silent as normal when "navigation" in window is false.

Common pitfalls

  • Reloading on the first controllerchange. On a first visit, clients.claim() fires controllerchange without any update. Track the previous controller and ignore the change from none to one.
  • Reloading every tab. Only the tab that accepted should reload. Others should show a stale banner.
  • skipWaiting() plus aggressive cache cleanup. Deleting the previous precache in activate is what turns skew into 404s. Keep one previous version, or don't skip waiting.
  • Counting on reloads. A reload doesn't activate a waiting worker. Test updates by closing every tab, or use DevTools' Update on reload only when you understand it forces a new version on every navigation.
  • Forgetting installed apps. They rarely close and resuming isn't a navigation. Call registration.update() on visibilitychange and apply updates at navigations.
  • Putting a changing value in sw.js. A build timestamp or nonce rendered into every response makes every check an update, which combined with Pattern 5 is a reload loop.
  • No emergency exit. Whatever pattern you pick, keep a kill-switch worker ready. Pitfalls & Anti-Patterns and Updating Service Workers explain how.

Further reading

On this site

External references