Web Platform · Progressive Web

Service Worker Update Semantics and skipWaiting

How byte comparison decides whether a new worker installs, what updateViaCache changes about that check, and why calling skipWaiting while old documents are open produces mixed-version clients.

A deployed service worker is a program that the browser keeps running after the deployment that produced it is gone. Replacing it is not a matter of serving new bytes: the browser must notice the change, install a second worker alongside the first, and decide when the second may take over. Each of those steps has a rule that can leave a site serving a mixture of versions if it is ignored.

What counts as an update

The update algorithm fetches the worker’s top-level script and compares it byte for byte with the stored copy. A difference in any byte — a comment, a build hash, a whitespace change — produces a new worker and an install. Identical bytes produce no new worker even if the response carries a later Last-Modified, and no amount of cache clearing changes that, because the comparison is against the stored script, not against HTTP metadata.

Imported scripts are treated differently. importScripts() and module imports are fetched during the worker’s initial evaluation and stored alongside it; a change to an imported file with no change to the top-level script is not detected. The top-level script must change for the imports to be re-fetched. This is why build systems emit a worker entry file whose content includes a hash of its dependencies, and why a worker split across modules must still have one file whose bytes track the whole build.

The check is also throttled. A registration that was checked recently is not checked again immediately; browsers apply a limit on the order of one check per day for navigations. registration.update() requests a check outside that schedule, subject to its own rate limiting, and the browser makes no promise about how quickly after that the check completes. A deployment followed by an immediate reload of the site, therefore, may show the old worker until a navigation after the throttle expires — which is why a worker should be designed to be correct for at least a day after deployment, not merely correct once the new version activates.

The updateViaCache switch

The script request crosses the HTTP cache, and updateViaCache decides whether it may be answered from there. The option is passed at registration and stored with the registration.

The default, 'imports', means the top-level script is revalidated with the server while imported scripts may come from the HTTP cache. 'all' allows the HTTP cache to satisfy both, and 'none' forces revalidation for both. The asymmetry in the default is deliberate: the top-level script is the trigger for updates and must be fresh, while imports are validated by the top-level script having changed.

The practical consequence is that imported scripts served with long-lived immutable caching can prevent a fix from taking effect even after the top-level script changes, because the import is re-fetched from the HTTP cache and returns the old bytes. Either the imports carry content-hashed URLs so a changed import has a changed URL, or the server sends a revalidation-required cache policy for workers and their imports.

skipWaiting and the mixed-version client

self.skipWaiting() resolves the waiting state early: the new worker activates even though clients from the old version still exist. clients.claim() then makes it control those clients, which otherwise remain uncontrolled until their next navigation.

Both calls change what code runs where, not what code the page already loaded. A document that was built by the old version keeps its scripts in memory; after claim(), its subresource requests go to the new worker’s handler, which reads the new caches. The old document now requests chunks that the new deployment renamed, and gets a cache miss at best and a stale redirected response at worst. This is the mixed-version client: HTML and JavaScript from build N, network interception and caches from build N+1.

The failure is easy to reproduce with route-level code splitting. A build renames chunk-a1b2.js to chunk-c3d4.js, the new worker’s activate step deletes the old cache containing chunk-a1b2.js, and the open document’s lazy route import fails. The user sees a broken page on a site that is working correctly from a fresh load.

Unconditional skipWaiting() in the install handler is a common cause. It is also frequently recommended as a fix for “my updates do not apply”, which treats the symptom of a waiting worker by removing the protection that prevented the inconsistency.

Sequencing the swap deliberately

The safe arrangement keeps the old worker active until the documents it controls are ready to go. Three pieces make that possible.

First, do not call skipWaiting() during install. Let the new worker wait, and expose the fact through registration.waiting. Second, let the page decide: a page that detects a waiting worker can show a reload affordance, and only on the user’s action send a message asking the worker to activate. Third, reload the page when control changes, so the document and its subresources come from one version.

// Page: ask the waiting worker to activate, then reload once it controls this page.
let reloaded = false;

navigator.serviceWorker.addEventListener('controllerchange', () => {
  // Guard against reload loops: only reload when the controller actually changed.
  if (reloaded) return;
  reloaded = true;
  location.reload();
});

const registration = await navigator.serviceWorker.ready;
if (registration.waiting) {
  registration.waiting.postMessage({ type: 'activate-now' });
}
// Worker: activate only on an explicit request, never during install.
self.addEventListener('message', (event) => {
  if (event.data?.type === 'activate-now') self.skipWaiting();
});

The controllerchange listener must be registered before the message is sent, or the event can fire in the interval and the reload never happens. The one-shot flag prevents a second controllerchange — possible when multiple tabs each request an update — from causing a second reload of a page that is already current.

A page with unsaved state should not force this sequence. A common compromise is to detect the waiting worker, warn about the update, and defer the reload until the user is idle or has saved; the worker can wait indefinitely, and the browser will activate it automatically once the last old client navigates away.

Versioned clients and versioned caches

When mixed versions cannot be avoided — two tabs, one of them long-lived — the API boundary between page and worker has to tolerate a version skew. A worker that answers a message with a shape the page does not understand will break the page; a page that sends a message the worker does not recognize will hang waiting for a reply. Including a protocol version in every message and having both sides ignore unknown types converts a hard failure into a degraded feature.

Caches should be versioned by name so that an activation deletes only its own generation, as described in Cache Storage Keys, Vary, and Cache Versioning. If old caches are retained for one generation instead of deleted immediately, a mixed-version document can still find the chunk it expects. The cost is storage: one extra generation of the precache, which for a shell of a few hundred kilobytes is cheap insurance and for a media cache is not.

Deployment rules that prevent the class of bug

The worker script and its imports are fetched by the browser, not by the page, and their cache policy is part of the deployment. With updateViaCache: 'all', a long max-age on the top-level script can satisfy the update check from the HTTP cache for the life of the entry; under the default 'imports', the same applies to imports, which are fetched with the default cache mode. 'none' sets the update request’s cache mode to no-cache, which forces revalidation with the server rather than bypassing it, so a long max-age does not hide a changed script in that mode. A revalidating policy for worker scripts removes the ambiguity: they are small, they are requested rarely, and their staleness is expensive.

Content-hashed URLs for every other asset make the deletion step safe. If a document from build N requests only URLs containing build N’s hash, a worker from build N+1 that deletes build N’s cache cannot break it — the request goes to the network, which either still has the file or returns a 404 that the document’s error boundary handles. Hashed filenames also make the byte comparison meaningful for the worker entry, since the entry’s content changes when any dependency’s hash changes.

Finally, the update path needs to be observable in development. Browsers provide a devtools option to bypass the worker and to update on reload, and using it turns a class of bug that appears only after deployment into one that appears on the next refresh. The lifecycle states that make the sequence possible are described in Service Worker Lifecycle and the Registration Scope. Keeping an extra cache generation as insurance is a storage decision rather than a correctness one, and its cost is weighed in Storage Quota, Persistence, and Eviction.

The remaining risk is not technical but procedural: an origin with several workers, each released by a different process, can update one and not the other, producing a page from one generation and an offline handler from another that never agree on cache names. A single owner for the worker’s build output is the only reliable fix.

References

  1. Service Workers Nightly — W3C Web Applications Working Group
  2. ServiceWorkerGlobalScope.skipWaiting() — MDN Web Docs
  3. Clients.claim() — MDN Web Docs