Server code you deploy, you replace: the old version disappears, the new one answers everyone at once. A service worker does not work that way. It is a background script the browser installs onto a device once, and from then on it lives there on its own, intercepting the page's requests and working with no network. The browser re-checks sw.js on a navigation within scope, and on functional events such as push and sync - but for those no more than once every 24 hours. There is no periodic check: without a navigation and without such events the update is never found at all. And even when it finds a new version it holds it in the waiting state until every tab with the old worker closes. So a bad deploy is not a one-command rollback: the broken code is already running on thousands of devices, on different versions, for hours or days. Rollout has to be designed as compatibility and an emergency exit, not as a switch.
The naive move is to publish a new sw.js and new bundles and call it done. But hashed assets (chunk-a1b2.js) are immutable and cached for a long time, and client versions mix. The new sw.js references chunk-a1b2.js, yet a device still runs the old worker: it serves the old index, which asks for chunk-old.js - and if you have already deleted that file from the CDN, the client gets a 404. In the orders app this means one user on version N, another on N-1, both knocking on the same API, and the backend obliged to understand both.
The main control lever is HTTP caching of the two asset classes. The sw.js itself must have a short max-age or no-cache with revalidation, so the browser picks up the new worker quickly; registering with updateViaCache: 'none' additionally forbids the HTTP cache from serving a stale worker script during the update check. Hashed assets are the reverse: immutable and a one-year max-age, because the hash in the name is the version, and such a file is safe to cache forever. Keep old hashed files on the CDN for at least the whole update window of active clients.
Because clients update out of step, the backend must speak both contracts, N and N-1, for that entire period. Changes are additive only: add a new field, do not remove the old one while a client that reads it is still alive. The same holds for the local schema: an IndexedDB migration through upgradeneeded must stay readable by an old tab in a neighboring window, or that tab breaks in the middle of the user's session.
Roll out gradually. A canary is a check of the new version on a narrow audience before the general release: bring the new worker up on a separate scope or origin, watch its error rate, and only then roll it out to a fraction of traffic (a staged rollout). Make the worker version a first-class signal: embed it in the code, log it to diagnostics and to server logs, so that when errors climb you can see which version is actually talking to the server right now, not guess.
When a deploy does turn out broken, you need an escape path - a kill switch. Publish to the same sw.js URL a minimal worker that, on activation, wipes all caches, deregisters itself via unregister, and reloads the windows - a clean slate on the next load.
Two caveats make this safe. First: this code runs only after the browser re-checks sw.js and activates the new worker - which may happen in hours, or never. So a server or CDN fallback (a fresh index with correct headers) is still mandatory: the kill switch is the client half of the cure, not the whole medicine. Second: never wipe IndexedDB in a generic kill switch - it may hold the user's unsynced orders, and clearing caches is reversible while destroying pending writes is not.
// deploy to the same sw.js URL
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', event => {
event.waitUntil((async () => {
for (const key of await caches.keys()) await caches.delete(key);
await self.registration.unregister();
const windows = await self.clients.matchAll({ type: 'window' });
for (const client of windows) client.navigate(client.url);
})());
});