Ship a Fix Users Can't See (Service Worker Edition)
You deployed, you hard-refreshed, they still get the old page. How the service worker update cycle actually works, and how to force it.
Loading…
You deployed, you hard-refreshed, they still get the old page. How the service worker update cycle actually works, and how to force it.
Loading…
The deploy is live. You see the fix. A user on the same URL still gets the build from last week, and "clear your cache" does not help them.
A service worker sits in front of the network. Once installed, it serves the page from its own cache — the new HTML never gets a chance to load, so the browser never learns there is a new worker to install.
Step 3 is the part that surprises people. A hard refresh reloads the page; it does not release the worker.
DevTools → Application → Service Workers. If you see a worker marked waiting to activate, your deploy shipped fine and the client is simply holding the old one. From the console:
const reg = await navigator.serviceWorker.getRegistration();
console.log({
active: reg?.active?.scriptURL,
waiting: !!reg?.waiting, // new build, stuck behind the old one
installing: !!reg?.installing
});
In the worker, take over as soon as you install:
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));
skipWaiting() promotes the new worker past the waiting state;
clients.claim() puts already-open tabs under its control. Do both — either
one alone leaves a gap.
The trade-off is real: a tab that loaded old JS may now be served new assets mid-session, and a chunk it asks for later may no longer exist. The polite version is to keep the wait and offer a reload:
reg.addEventListener("updatefound", () => {
const next = reg.installing;
next?.addEventListener("statechange", () => {
if (next.state === "installed" && navigator.serviceWorker.controller) {
// A new version is ready — show a "Refresh to update" prompt.
}
});
});
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map((r) => r.unregister()));
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
location.reload();
That is a debugging tool, not a fix to ship — it only cleans the machine it runs on.
Don't cache the HTML document with a cache-first strategy. Documents want
network-first with a cached fallback; hashed assets (/_next/static/…) are
the ones safe to serve cache-first, because a new build gives them new
filenames anyway.