Skip to content

The service worker update flow and skipWaiting

In one line: When the browser finds a byte-different service worker script, it installs the new version alongside the old one and holds it in the waiting state until every controlled tab closes — unless you call self.skipWaiting() to bypass that hold.

The browser re-fetches the worker script automatically on every navigation (within scope) and on any explicit registration.update() call. If the fetched script differs by even one byte from the currently registered one, the browser starts a new install.

The update check itself uses a special freshness rule: the worker script bypasses the HTTP cache for checks older than 24 hours, even if the server has sent aggressive Cache-Control headers. The updateViaCache: 'none' registration option makes every check bypass the cache entirely.

After the new worker installs successfully (its install event resolves), it enters waiting. It will not activate — and therefore will not handle fetch events — until:

  1. All tabs controlled by the old worker are closed, or
  2. The new worker calls self.skipWaiting().

This design guarantees that a page and the worker serving it are always from the same version. A tab running old HTML is never handed to a new worker that may have different cache keys or API assumptions.

Calling skipWaiting() inside the install event tells the browser to activate the new worker immediately, even if the old one still controls open tabs:

self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('app-v2').then((cache) => cache.addAll(['/index.html', '/app.js']))
);
self.skipWaiting(); // activate immediately after install
});

skipWaiting() is asynchronous but its effect is usually immediate relative to the current task. Calling it outside install is also valid but the most reliable place is inside waitUntil.

Risk: If the new worker’s cached resources are incompatible with the HTML already loaded by the old worker, calling skipWaiting() without reloading all tabs can cause runtime errors. Always pair it with a controllerchange listener.

By default a newly activated worker does not control pages that were already open when it was registered — they run without a service worker for their current lifetime. clients.claim() lets the new worker adopt those pages immediately:

self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== 'app-v2').map((k) => caches.delete(k)))
)
);
self.clients.claim(); // take control of all open tabs in scope
});

Use clients.claim() only when you have a clear reason — for example, ensuring the very first page load after registration is controlled (so offline works immediately). Unnecessary use of claim() causes all currently-open tabs to swap workers mid-session.

When skipWaiting() activates a new worker, existing tabs are still running old code. The recommended pattern is to listen for controllerchange in the page and prompt for a reload:

// In the page
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});

Alternatively, use the registration.waiting property to detect a pending update and show a “reload for update” UI instead of forcing an automatic reload.

Call registration.update() to trigger an immediate check for a new worker script:

navigator.serviceWorker.ready.then((registration) => {
registration.update();
});

The raw Service Workers API requires you to call registration.update() yourself for proactive update detection. If using Workbox, its workbox-window package provides wb.addEventListener('waiting', ...) for reacting to updates, but periodic re-checking still requires an explicit call to wb.update() or the browser’s own navigation-triggered check.

  • Use updateViaCache: 'none' when registering to ensure the browser always re-fetches the worker script.
  • If you use skipWaiting(), also add a controllerchange listener in the page to reload tabs and avoid version mismatches.
  • Clean up old cache names in the activate event after skipWaiting() ensures the new worker is active.
  • Consider a visible “Update available — click to reload” UI rather than silent auto-reload for critical apps.
  • Test the update flow by loading the page, changing the worker, then navigating (not just refreshing in DevTools).