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.
How the browser detects an update
Section titled “How the browser detects an update”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.
The waiting state
Section titled “The waiting state”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:
- All tabs controlled by the old worker are closed, or
- 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.
self.skipWaiting()
Section titled “self.skipWaiting()”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.
clients.claim()
Section titled “clients.claim()”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.
Notifying the user
Section titled “Notifying the user”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 pagelet 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.
Manual update check
Section titled “Manual update check”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.
Practical checklist
Section titled “Practical checklist”- Use
updateViaCache: 'none'when registering to ensure the browser always re-fetches the worker script. - If you use
skipWaiting(), also add acontrollerchangelistener in the page to reload tabs and avoid version mismatches. - Clean up old cache names in the
activateevent afterskipWaiting()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).
Cross-references
Section titled “Cross-references”- Service worker lifecycle — full install / activate / waiting flow
- Registration and scope —
updateViaCacheoption onregister() - Debugging service workers — use DevTools to force updates and inspect waiting workers