Your Service Worker Is A Deploy You Cannot Roll Back. Stop Shipping It Like A Script.
Once a service worker is installed, the browser decides when your new release runs, and rollback cannot reach it. Why reloading does not activate a new worker, how skipWaiting breaks lazy chunks in open tabs, and the kill switch to write before you need it.
I Deployed v2. I Reloaded Twice. The Page Still Said v1.
The test app was about as small as a web app can be. One HTML page with a heading that printed its version, one JavaScript file that registered /sw.js, and one lazily imported chunk. The service worker precached all three on install and served them cache-first. Nothing clever. It is roughly the shape of what most PWA plugins generate for you.
I opened it in Chromium, let the worker take control, then flipped the server to v2. New HTML, new hashed file names, old files deleted from the server, exactly like a normal deploy to a CDN. Then I reloaded.
v1. Reloaded again. Still v1. The server had not served a single v1 byte for a while, and the page was still happily running the old release. Only when I closed every tab for that origin and opened a fresh one did v2 appear.
If you have ever read a support ticket that says "the fix is not live for me" and replied "try a hard refresh", this is the mechanism behind it. And it is not a bug. It is the service worker lifecycle doing precisely what the spec says it should.
Here is the mental model I think most teams carry, and why it is wrong. We think of sw.js as one more script in the bundle. It is not. It is a small server that you install on your users' machines, which sits between your app and the network and answers requests on your behalf. Once it is installed, the browser decides when your new release takes over, not your deploy pipeline. Your CDN purge does not reach it. Your rollback does not reach it. Your feature flag might not reach it either, if the flag lives in a cached file.
A service worker is not part of your bundle. It is a deploy target you do not control, sitting on every user's machine.
That framing changes how you should ship it. The rest of this piece is what I verified, what the lifecycle actually does, the "fix" that breaks open tabs, and the release discipline I would put in place before adding a service worker to any serious production app.
Install, Wait, Activate: The Part Everyone Skims
A service worker goes through a fixed sequence, and the interesting step is the one in the middle.
- Update check. The browser fetches your service worker script again. Per the lifecycle docs, this happens on a navigation to an in-scope page, on functional events like
pushandsync(unless there was a check in the previous 24 hours), or when you callregistration.update(). - Byte comparison. If the new script is byte-for-byte identical to the installed one, nothing happens. If it differs, the new worker starts installing alongside the old one.
- Install. The new worker runs its
installhandler, usually precaching the new release's files. - Waiting. Installed, but not in charge. The old worker keeps controlling every open page.
- Activate. Only when no page is controlled by the old worker any more does the new one take over, run
activate, and usually delete the old caches.
That waiting step is the whole story. It exists for a very good reason: a page that loaded with release N expects release N's assets and release N's fetch handling. Swapping the worker underneath it would mean the page's first half was served by one release and its second half by another. The browser refuses to do that to you by default.
Why a reload does not count
The part that surprises people is that a reload does not end the waiting phase, even with only one tab open. My repro showed it clearly: after the deploy, the reload triggered the update check, v2 installed and went straight into waiting, and the page came back as v1 again. A second reload changed nothing.
The web.dev lifecycle article explains why. During a navigation, the current page does not go away until the new response starts arriving, so there is always an overlap where the old page still exists. Because of that overlap, the old worker is always controlling a client during a refresh, so it never becomes idle, so the new one never activates.
So when someone says "users get the new version on next visit", the honest version is: users get the new version on the next visit after they have closed every tab of your site, and even then they are looking at whatever the new worker precached, which might not be what you deployed five minutes ago if another deploy landed in between.
What I Actually Tested
I did not want to write this from memory of blog posts, so I built the smallest thing that could show each behaviour and drove it with Playwright in headless Chromium. The server exposed a hidden endpoint to "deploy" a new version, which swapped the HTML, the hashed file names and the service worker, and made the old files return 404, like a real deploy that replaces the contents of a bucket.
const V = "v1"; self.addEventListener("install", (e) => { e.waitUntil( caches.open("app-" + V).then((c) => c.addAll(["/", "/app-v1.js", "/chunk-v1.js"]) ) ); }); self.addEventListener("activate", (e) => { // Delete every cache that is not this release's e.waitUntil((async () => { for (const k of await caches.keys()) if (k !== "app-" + V) await caches.delete(k); })()); }); self.addEventListener("fetch", (e) => { e.respondWith( caches.match(e.request).then((r) => r || fetch(e.request)) ); });
Three scenarios, each in a fresh browser profile:
| Scenario | What I did | What happened |
|---|---|---|
| Default lifecycle | Deploy v2, reload twice, then close all tabs and reopen | v1 after both reloads with v2 waiting. v2 only after every tab closed. Old cache deleted on activate. |
skipWaiting + clients.claim |
Deploy v2, trigger an update check, then lazy-load a chunk from the still-open v1 tab | v2 took over the open tab, deleted the v1 cache, and the lazy import failed: Failed to fetch dynamically imported module. |
| Kill switch | Deploy a worker that clears caches and unregisters itself | One reload later: no controller, no caches, no registration, fresh v2 from the network. |
The first row is the "why is my fix not live" problem. The second row is what happens when you "fix" the first one the obvious way. The third row is the thing you need to have ready before either becomes an incident.
This was Chromium only, via Playwright, on localhost. The lifecycle is specified behaviour and the other engines follow the same install / waiting / activate model, but I would still verify timing-sensitive details on Safari and Firefox before relying on them in a release plan.
skipWaiting Fixes Stale Tabs By Breaking Them
Once a team discovers the waiting phase, the fix everyone finds is two lines:
self.addEventListener("install", () => self.skipWaiting()); self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
skipWaiting() tells the browser to activate the new worker as soon as it is installed, without waiting for old pages to close. clients.claim() makes the newly active worker take control of pages that are already open. Together, they mean the new release takes over every open tab almost immediately.
That sounds like exactly what you wanted. Here is what it does to a tab that loaded v1 and is still open, which in my repro took about two seconds to demonstrate:
- The page is running v1 JavaScript, which knows about
/chunk-v1.js. - The v2 worker activates, and its
activatehandler deletes theapp-v1cache, because that is what every "clean up old caches" example tells you to do. - The user clicks something that lazily imports
/chunk-v1.js. - The v2 worker checks its cache. Not there. It falls through to the network. The deploy removed that file. 404.
- The dynamic import rejects. In a React app, that is a
ChunkLoadErroror a blank route, depending on your error boundaries.
skipWaiting does not make users get the new release. It makes old pages run under the new release's rules.
The web.dev lifecycle guide says this plainly: with skipWaiting(), your new worker is likely controlling pages that were loaded with an older version, and if that might break things, do not use it. Code splitting is precisely the thing that makes it break, because a code-split page keeps fetching its own release's files for as long as it is open.
This is probably already in your config
You may be doing this without ever writing skipWaiting. In vite-plugin-pwa, registerType: 'autoUpdate' forces Workbox's clientsClaim and skipWaiting to true, according to its own docs. Those docs are refreshingly honest about the cost: users can lose data in any open tab where they are filling in a form, and they recommend the prompt-based approach for apps with forms.
I think that warning undersells it slightly. Lost form data is the visible failure. The quieter one is a lazy route that fails to load for a user who kept a tab open over your lunchtime deploy, which shows up in your error tracker as a scattering of chunk errors that nobody can reproduce, because by the time anyone looks, their own tab is on the new release.
If your app is a single bundle with no lazy chunks, or your server keeps old hashed assets around for a while after each deploy, the chunk failure mostly goes away. skipWaiting is a legitimate tool. It is just a decision about mixing releases, and it should be made on purpose, not copied from a snippet.
Your Rollback Button Talks To The Server. The Problem Is On The Client.
Now take the scenario that actually hurts. You ship a service worker with a bug in its fetch handler. Maybe it caches an API response it should not, maybe it serves a stale index.html that points at chunks you have since deleted, maybe it swallows a redirect. You notice, and you roll back the deploy.
For users who never got the bad worker, fine. For users who did, think about what has to happen for the rollback to reach them:
- They have to navigate to your site so the browser runs an update check. The bad worker is still answering that navigation, possibly with a cached, broken page.
- The browser has to fetch
sw.jsand see that it changed. Your rolled-back file is different from the bad one, so this part works. - The rolled-back worker then installs and waits, because the bad worker is controlling their open tabs.
- They have to close every tab for your origin. Then, and only then, does the good worker take over.
Every step in that list is outside your control. There is no server-side action that ejects an installed worker from a user's browser immediately. The closest thing is the Clear-Site-Data header, which I will come back to, and even that only runs when the browser actually receives a response from your server.
The cache-header trap is mostly gone, but not entirely
A few years ago there was a nastier version of this. If your host served sw.js with a long Cache-Control: max-age, the update check itself could be answered from the HTTP cache, so browsers would not even notice a new worker. The web.dev lifecycle article notes that most browsers, including Chrome from version 68, now ignore caching headers when checking the registered worker script. That is the updateViaCache default, 'imports'.
The catch is in that default's name. Scripts pulled in with importScripts() still respect HTTP caching headers. If your worker is a thin shell that imports /sw-logic.js, and that file has a long cache lifetime, the logic can stay stale even after you have fixed it. I still serve every service worker file with Cache-Control: no-cache, because it costs nothing and removes a whole category of "is it the cache?" debugging.
Do not rename the worker to force an update
The tempting workaround is to give the worker a hashed file name, sw.4f2a.js, like every other asset. The lifecycle docs have a heading for this, and it is essentially "don't". The reason follows directly from the repro: the page that registers the worker is itself served by the old worker. If that cached page says register('/sw.4f2a.js'), it will keep registering the old URL forever. The new URL is only ever referenced by HTML that the old worker never lets the user see. Keep one stable URL, and change its contents.
Write The Kill Switch Before You Need It
Every service worker deploy should come with an answer to one question: if this worker turns out to be bad, how do I get it off every machine it is on? The answer should be a file you have already written and tested, not something you are drafting during an incident.
This is the one I tested. It replaces the bad worker at the same URL, so the browser's normal update check finds it:
// Activate immediately - this is the one case where mixing releases is the point. self.addEventListener("install", () => self.skipWaiting()); self.addEventListener("activate", (e) => { e.waitUntil((async () => { for (const key of await caches.keys()) await caches.delete(key); await self.registration.unregister(); // Reload open tabs so they come back from the network, uncontrolled const tabs = await self.clients.matchAll({ type: "window" }); for (const tab of tabs) tab.navigate(tab.url); })()); }); // No fetch handler: nothing is intercepted while this worker is alive.
In the repro, one reload after deploying this was enough: the page came back as fresh v2 from the network, with no controller, no caches, and getRegistration() returning nothing. Note the irony. This is the one place where skipWaiting() is exactly right, because the whole point is to stop honouring the old release as fast as possible, and the tabs get navigated anyway.
Two details matter here. First, the kill switch has no fetch handler at all. A worker without a fetch handler does not intercept requests, so even during the short window before it unregisters, the network answers directly. Second, client.navigate() reloads tabs that might contain unsaved work. For an incident where the old worker is serving broken pages, that is the right trade. For a planned retirement, you might prefer to skip the navigation and let tabs pick up the change on their own.
Clear-Site-Data as a second lever
The Clear-Site-Data response header is the server-side version of the same idea. MDN documents that the "storage" directive removes DOM storage for the origin, including service worker registrations, by running unregister() on each one. It also clears localStorage and IndexedDB, so it is a blunt instrument. It can wipe drafts and offline data your users actually care about. MDN also flags that some parts of the feature have varying support, so check the compatibility table for the browsers you care about before treating it as your only plan.
I treat it as a backup, sent from one specific endpoint rather than from every response, and I keep the self-unregistering worker as the primary kill switch because I can test it end to end.
Let The User Decide When Releases Switch
If the default lifecycle is too slow and skipWaiting on install is too dangerous, the middle ground is to install silently and switch on a signal. The user, or the app at a safe moment, decides when the new release takes over.
self.addEventListener("message", (e) => { if (e.data?.type === "SKIP_WAITING") self.skipWaiting(); });
const reg = await navigator.serviceWorker.register("/sw.js"); function offerUpdate(worker: ServiceWorker) { showBanner("A new version is ready", () => { worker.postMessage({ type: "SKIP_WAITING" }); }); } if (reg.waiting) offerUpdate(reg.waiting); reg.addEventListener("updatefound", () => { const next = reg.installing; next?.addEventListener("statechange", () => { if (next.state === "installed" && navigator.serviceWorker.controller) offerUpdate(next); }); }); // Once the new worker takes over, reload so the page and worker are the same release let reloading = false; navigator.serviceWorker.addEventListener("controllerchange", () => { if (reloading) return; reloading = true; location.reload(); });
The controllerchange reload is the important bit. It closes the gap that skipWaiting opens: the moment the new worker takes control, the page reloads and becomes a new-release page, so it never tries to fetch old chunks under the new worker's rules. The navigator.serviceWorker.controller check stops the banner from showing on the very first install, when there was no old release to replace.
If you use Workbox, workbox-window packages this pattern up, and vite-plugin-pwa's default 'prompt' mode is built on the same idea. I would take either over hand-rolling it. The point is not the specific library. It is that the switch between releases becomes an explicit event that you can reason about, rather than something that happens to an open tab at a random moment.
A banner is not the only trigger, either. Some apps switch on the next route change, since a route change is a natural moment where losing in-memory state is expected. That is a reasonable choice as long as it is a choice.
Keep Old Chunks Around And Catch The Ones You Missed
Even with a good update flow, you will have users on old releases for a while. Two cheap habits make that far less painful.
Do not delete the previous release's assets on deploy
Most of the chunk failures in this piece come from one assumption: that the moment you deploy v2, v1's hashed files can disappear. With a service worker in the picture, that is rarely true. Some tabs will still be v1 for hours or days. Hashed file names never collide, so keeping the last release or two of assets on your CDN costs you some storage and buys you a lot of quiet. If your deploy step syncs a bucket with a "delete files not in the new build" flag, that flag is worth a second look.
Treat a chunk load failure as "I am stale"
When a lazy import fails, the most likely explanation in a service-worker app is that this tab is running an old release. The recovery is to reload once, and only once:
export function lazyWithReload<T>(load: () => Promise<T>): () => Promise<T> { return async () => { try { const mod = await load(); sessionStorage.removeItem("chunk-reload"); return mod; } catch (err) { // Guard against a reload loop if the chunk is genuinely broken if (!sessionStorage.getItem("chunk-reload")) { sessionStorage.setItem("chunk-reload", "1"); location.reload(); } throw err; } }; }
Be clear about what this does and does not fix. It reloads the page, and on a default lifecycle the old worker may still be in charge after that reload, as the repro showed. What it fixes is the specific skipWaiting case, where the new worker is already active and the page is the only thing that is out of date. It is a safety net for mixed releases, not a replacement for a proper update flow. Vite also fires a vite:preloadError event on window when a preload fails, which is a convenient single place to hang the same logic.
The Checklist I Would Put In Front Of Any Service Worker PR
None of the behaviour above is exotic. It is the documented lifecycle. The failures come from treating the worker like any other file in the build. So I would review a service worker change the way I would review a database migration: as something that is hard to undo once it has run.
- Does it have to exist? If you want offline support or push, yes. If you added it for "performance", HTTP caching of hashed assets already does most of that without a program running in your users' browsers.
- Is the kill switch written and tested? A self-unregistering worker at the same URL, checked into the repo, with a runbook line saying how to deploy it.
- Is
skipWaitinga decision? If it is on, either there are no lazy chunks, or old assets stay available, or the page reloads oncontrollerchange. Check your PWA plugin's defaults, not just your own code. - Is
sw.jsserved withno-cache? Including anything it pulls in withimportScripts(). - Is the URL stable? No hashes in the worker's own file name.
- What does it cache that is not a static asset? Cached HTML and cached API responses are where the scary incidents come from. Every non-hashed response in a cache needs a reason and an expiry.
- Can you see which release users are on? Send the worker's version with your error and analytics events. Otherwise "the fix is not live for me" is a guess.
That last point deserves more attention than it gets. A service worker splits your user base across releases by design. If your telemetry cannot tell a v1 page under a v2 worker from a clean v2 session, you will spend incidents arguing about whether the fix is deployed when the real question is whether it has been adopted.
Deployed Is Not The Same As Running
If you keep one thing from this, keep the short version:
- A service worker is a small server installed on users' machines. Your deploy updates the file. The browser decides when the new one runs.
- A new worker waits until every tab controlled by the old one closes. Reloading does not count, and I reproduced that directly.
skipWaitingplusclients.claimmakes old pages run under new rules. With code splitting, that means failed lazy imports.vite-plugin-pwa'sautoUpdateturns both on.- Rolling back the server does not roll back the worker. Write and test a self-unregistering kill switch before you need it.
- Prefer install-then-prompt with a reload on
controllerchange. Keep old assets on the CDN for a release or two. - Keep the worker at one stable URL, served with
no-cache.
I like service workers. Offline support and push are real features that nothing else in the browser gives you. But the teams that get burned are almost always the ones who added one through a plugin checkbox and never asked what happens on the second deploy. The first deploy is easy. It is every deploy after that where the lifecycle matters.
With a service worker, deployed and running are two different states, and the browser owns the gap between them.
skipWaiting plus clients.claim breaking a lazy import in an open tab, and the self-unregistering kill switch) were reproduced locally with a minimal two-version app driven by Playwright 1.59.1 against headless Chromium 147 on Node 26. Update-check triggers, the updateViaCache default and the advice against renaming the worker come from the web.dev service worker lifecycle article. The vite-plugin-pwa autoUpdate behaviour is from the plugin's own documentation, and Clear-Site-Data behaviour is from MDN. Verify timing details on Safari and Firefox before relying on them.Related essays
- JavaScript · Browser Internals
setTimeout(fn, 0) Is Not Zero. In A Background Tab, Your 100ms Timer Runs Once A Minute.
The delay you pass to setTimeout is a minimum. Measured in Chromium, Firefox, WebKit and Node: the 4ms nesting clamp, hidden tabs throttled to 1 second, Chrome's once-a-minute intensive throttling after 5 minutes, a 30-day timer that fires instantly, and what to use instead.
- JavaScript · Browser Internals
JavaScript Reads "2026-10-06" As UTC And "2026-10-06T00:00" As Local Time. Most Off-By-One Date Bugs Start There.
new Date("2026-10-06") is UTC midnight, new Date("2026-10-06T00:00") is local midnight. Tested in Chromium, Firefox and WebKit across three time zones: where the day slips, why Indian teams rarely see it, "06-10-2026" becoming 10 June, DST arithmetic, and what Temporal fixes.
- HTTP Caching · Frontend Infrastructure
Cache-Control: no-cache Does Not Mean Do Not Cache. And No Header At All Is Worse.
no-cache stores the response and revalidates it every time - no-store is the one that refuses to store. And an index.html with no Cache-Control at all is still cached, for a period the browser works out from Last-Modified. Reproduced in Chromium, with the two-rule setup I would use.