Engineering Diaries · Service Workers
§
Service WorkersPWAFrontend Infrastructure

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.

01 · The Reload That Did Nothing

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.

02 · The Lifecycle

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.

Service Worker Update Lifecycle
Update check
Bytes differ?
install
waiting (old worker still in charge)
all old clients closed
activate
  • 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 push and sync (unless there was a check in the previous 24 hours), or when you call registration.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 install handler, 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.

The one fact to remember A refreshed tab is never "closed" from the service worker's point of view. For a waiting worker to activate, every tab and window controlled by the old worker has to go away. On mobile, where people keep a PWA or a browser tab open for weeks, that can take a very long time.

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.

03 · The Repro

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.

sw.js · the v1 worker (simplified)
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:

ScenarioWhat I didWhat 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.

Scope of the test

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.

04 · The Obvious Fix

skipWaiting Fixes Stale Tabs By Breaking Them

Once a team discovers the waiting phase, the fix everyone finds is two lines:

sw.js · the tempting fix
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 activate handler deletes the app-v1 cache, 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 ChunkLoadError or 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.

Not an argument against skipWaiting everywhere

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.

05 · Why Rollback Does Not Work

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.js and 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.

06 · The Kill Switch

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:

sw.js · kill switch
// 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.

07 · A Better Update Flow

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.

sw.js · wait for permission
self.addEventListener("message", (e) => {
  if (e.data?.type === "SKIP_WAITING") self.skipWaiting();
});
register-sw.ts · in the app
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.

08 · Belt And Braces

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:

lazy-with-reload.ts
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.

1
Stable SW URL, forever
0
Seconds of max-age on sw.js
2
Releases of assets kept
1
Tested kill switch
09 · Release Discipline

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 skipWaiting a decision? If it is on, either there are no lazy chunks, or old assets stay available, or the page reloads on controllerchange. Check your PWA plugin's defaults, not just your own code.
  • Is sw.js served with no-cache? Including anything it pulls in with importScripts().
  • 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.

10 · The Short Version

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.
  • skipWaiting plus clients.claim makes old pages run under new rules. With code splitting, that means failed lazy imports. vite-plugin-pwa's autoUpdate turns 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.

The lifecycle behaviours described here (a waiting worker surviving reloads, 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.