Forcing a Service Worker Update After a Bad Deploy
You shipped a worker that throws in install, or a precache manifest pointing at files that never reached the origin, and the clients that already registered it are now pinned: they run the broken build, they do not fall back to the network, and no CDN purge you issue touches them. This is the one failure in front-end delivery where the recovery path is code you have to deploy rather than a button you can press.
The good news is that the browser is on your side. Every controlled navigation triggers a background check of the worker script, and the browser force-refreshes that check if the cached script is older than 24 hours. The recovery therefore reduces to a single question: when that check fires, does it reach an origin that will hand back different bytes?
Why the Clients Are Pinned
Three mechanisms combine to keep a bad worker in place longer than intuition suggests.
The update check is byte-comparison against the previously installed script. The browser refetches /sw.js, compares it to the script it installed last, and does nothing if they are identical. It does not compare against the currently cached HTTP response, and it does not care whether your CDN thinks the file changed. If your fix produces a byte-identical script — because a build cache short-circuited, or because the fix landed in an imported module rather than the entry — nothing happens.
The HTTP cache can satisfy that refetch. Browsers cap the worker script’s effective freshness at 24 hours, but within that window a Cache-Control: max-age=86400 on /sw.js means the update check is answered from disk and never reaches your origin. This is the single most common reason a fix appears to do nothing for a day. The registration option updateViaCache: 'none' disables it, but only for clients that registered after you added it — which, in an incident, is nobody.
A newly installed worker still waits. Even after the fixed script installs successfully, it sits in waiting until every tab controlled by the old worker closes. Users who keep a long-lived tab open never receive it. The kill-switch pattern below sidesteps this by calling skipWaiting() unconditionally, which is defensible precisely because a no-op worker has no state to corrupt.
When the browser checks on its own
Left alone, a browser refetches the worker script on three occasions: whenever a navigation is made to a page inside the registration’s scope, whenever a functional event such as push or sync fires and no check has run in the last 24 hours, and whenever registration.update() is called explicitly. There is no background timer — a tab that sits idle for a week performs zero update checks until something happens in it.
That has a practical consequence for incident estimates. Your recovery rate is bounded by your navigation rate, not by your deploy speed. If half your sessions are single-page visits that never navigate again, half your affected clients will not check until they return to the site. Publishing the fix does not mean the fleet is healthy; it means the fleet will become healthy at the speed users come back.
The request the browser sends carries Service-Worker: script, which is worth knowing because it lets you distinguish worker update checks from ordinary script requests in access logs. Watching the rate of those requests, and the ratio of 200s to 304s among them, is the cheapest possible progress indicator during a recovery — a rising count of 200 responses means clients are actually receiving new bytes.
Remediation Options
| Option | What it fixes | Blast radius | Time to recover | Requires |
|---|---|---|---|---|
Shorten /sw.js TTL and purge |
Update checks answered from cache | None | Next navigation, up to 24 h | Header control on one path |
registration.update() from the page |
Slow update pickup on long-lived tabs | None | Immediate for tabs that load the page | Page still renders |
| Ship a fixed worker | The specific bug | Normal deploy | Next update check | A correct build |
| Ship a no-op kill-switch worker | Any worker-side bug, including unknown ones | All worker features off | Next update check | One file |
registration.unregister() |
Removes control entirely | Worker gone until re-registered | Immediate on that client | Page still renders |
Clear-Site-Data: "storage" |
Poisoned Cache Storage and IndexedDB | Destroys local app state | On the next document load | Header control, tolerance for data loss |
Clear-Site-Data: "*" |
Everything, including cookies | Logs the user out | On the next document load | Last resort only |
Work down the table, not up. The first two rows are free and reversible; the last two are destructive and can generate a second incident — a support queue full of users asking why they were signed out.
Step 1: Make the Script Reachable
Before anything else, confirm that /sw.js is not being served with a TTL. If it is, fix the header and purge the path at the edge — the fix is worthless while update checks are answered from disk.
# Is the worker script cacheable anywhere in the chain?
curl -sI https://example.com/sw.js | grep -i 'cache-control\|age\|cf-cache-status\|etag'
# Cloudflare: purge just the worker script.
curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"files":["https://example.com/sw.js"]}'
# CloudFront: invalidate the same single path.
aws cloudfront create-invalidation \
--distribution-id EDFDVBD6EXAMPLE \
--paths '/sw.js'
A correct steady-state header for the script is no-cache, max-age=0, must-revalidate. no-store also works but throws away the conditional-request optimisation, so every check transfers the whole script rather than returning 304. The reasoning behind picking one over the other is the same as for HTML entry points in the Cache-Control and TTL reference.
Step 2: Ship the Kill-Switch Worker
If you know the fix and trust it, deploy it. If you do not — the failure is intermittent, the stack trace is minified, the incident is escalating — replace the worker with one that does nothing except tear down everything the previous one built. This is a two-line file and it is the highest-value artefact in your incident kit.
// sw.js — kill switch. Deploy this in place of the broken worker.
// It claims control immediately, deletes every cache this origin owns,
// and then unregisters itself so the next load is a plain network load.
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(names.map((name) => caches.delete(name)));
await self.clients.claim();
await self.registration.unregister();
// Reload every controlled tab so it re-fetches documents from the network.
const clients = await self.clients.matchAll({ type: 'window' });
for (const client of clients) {
client.navigate(client.url);
}
})());
});
// No fetch handler at all: every request goes straight to the network.
The absence of a fetch listener is the important part. A worker with no fetch handler is functionally transparent — the browser routes requests as though no worker were installed — so even if activate fails halfway through, clients are no worse off than an uncontrolled tab.
self.registration.unregister() inside the worker is safe: the registration is torn down but the current worker keeps running until its clients unload, which is why the client.navigate() loop follows it. Ship the kill switch, purge /sw.js, and let the update checks do the rest. Once the fleet is healthy you deploy a correct worker over the top, and clients register it on their next visit.
Step 3: Force the Check From the Page
The kill switch only lands when an update check runs. You can trigger one from application code — which still works, because the page itself loads over the network if your document route is network-first.
// recover.js — inline this in the document head during an incident.
(async () => {
if (!('serviceWorker' in navigator)) return;
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
try {
// Force a byte-comparison refetch of the script, bypassing the HTTP cache.
await registration.update();
} catch (err) {
// Update failed (offline, 404, or a script that no longer parses):
// fall back to removing the registration outright.
await registration.unregister();
}
}
// Drop any caches this origin owns that are not from the current build.
const expected = new Set(['assets-' + window.__BUILD_ID__, 'docs-' + window.__BUILD_ID__]);
for (const name of await caches.keys()) {
if (!expected.has(name)) await caches.delete(name);
}
})();
Inline it in the document rather than shipping it as a hashed asset. During an incident the asset URL may be exactly what is failing to load.
Step 4: Clear-Site-Data as the Last Rung
Clear-Site-Data is a response header that instructs the browser to purge storage for the origin. The "storage" directive covers Cache Storage, IndexedDB, localStorage, sessionStorage, and — importantly — it unregisters service workers.
// Cloudflare Worker: serve documents with a one-shot storage wipe.
// Gate it behind a flag; leaving this on permanently destroys offline support.
export default {
async fetch(request, env) {
const response = await env.ASSETS.fetch(request);
const isDocument = request.headers.get('Accept')?.includes('text/html');
if (!isDocument || env.KILL_SWITCH !== 'on') return response;
const headers = new Headers(response.headers);
// "storage" removes Cache Storage, IndexedDB, and service worker
// registrations. It does NOT touch cookies, so sessions survive.
headers.set('Clear-Site-Data', '"storage"');
headers.set('Cache-Control', 'no-store');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
},
};
Three constraints. The header is only honoured over HTTPS. It applies to the origin of the response that carried it, so serving it from an API subdomain does nothing for your document origin. And it takes effect on the next document load, not the current one, so users see one more broken render before recovery. Turn KILL_SWITCH off within the hour — leaving it on means every visit wipes storage forever and the worker can never install.
Prefer "storage" to "*". The wildcard adds "cookies", which signs every user out and converts a rendering incident into an authentication incident.
Verification
# 1. The kill switch is live and has no fetch handler.
curl -s https://example.com/sw.js | grep -c "addEventListener('fetch'"
# expect: 0
# 2. The script is not cacheable and the edge is not holding a copy.
curl -sI https://example.com/sw.js | grep -i 'cache-control\|age\|cf-cache-status'
# expect: cache-control: no-cache, max-age=0, must-revalidate / age: 0 / MISS or DYNAMIC
# 3. The script the browser would fetch differs from the broken one.
curl -s https://example.com/sw.js | sha256sum
In DevTools, open Application → Service Workers on an affected profile and confirm the registration disappears after one reload, then that Cache Storage is empty. If the registration persists, the update check is still being answered from cache — go back to step 1. Chrome’s chrome://serviceworker-internals page is worth having open too; it shows the script URL, the installed script’s last-update time, and lets you force an update per registration while you are validating the fix.
When to Reconsider
Do not reach for Clear-Site-Data when the worker is merely stale rather than broken. If the symptom is old HTML pointing at hashes that no longer resolve, the fix is a network-first document route and a longer asset retention window at the origin, not a storage wipe. That case is worked through in service worker serving stale HTML with new hashes.
Do not permanently disable the worker after one bad deploy. The failure is almost always a process gap — the manifest was generated before the assets were uploaded, or the glob pulled a file that the CDN never received. Fix the ordering in the pipeline, as described in the CI/CD asset pipeline guide, and add the post-deploy manifest reachability check from the parent guide.
Do not ship the kill switch and forget it. A no-op worker still occupies the registration slot and still runs an update check on every navigation. Replace it with a real worker once the fleet is healthy, or unregister and remove the registration call entirely.
Related
- Service worker cache invalidation — the parent guide covering lifecycle, cache versioning, and worker script TTLs
- Stale HTML referencing new hashes — the symptom most often mistaken for a broken worker
- Precache manifest versus runtime caching — why a smaller manifest reduces the odds of a failed install
- Cache-Control and TTL tuning — choosing the no-cache posture the worker script depends on
- Content hashing versus semantic versioning — why the worker script must never be fingerprinted