PannKs
UsefulTools & notes

Library

OverviewShort links
Prompts5
How To4
  • Free a Port a Dead Dev Server Still Holds
  • Ship a Fix Users Can't See (Service Worker Edition)
  • Stop the Page Jumping While It Loads
  • Truncate Text Without Breaking the Layout
← All How To

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.

tags
pwadebuggingweb
updated
2026-08-16

Loading…

← NewerFree a Port a Dead Dev Server Still HoldsOlder →Stop the Page Jumping While It Loads
Buy Me A Coffee

v.4.0.0 | Created By Next.JS 16.3.1

Pann Kaansadich © 2026

The symptom

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.

The update cycle, in the order it actually runs

  1. On navigation, the browser re-fetches the service worker script (not the page) and byte-compares it with the installed one
  2. Different by even one byte → the new worker installs, then sits in waiting
  3. The old worker stays in control until every tab under its scope is closed — reload is not enough, because a reload never leaves the page uncontrolled
  4. Only then does the new worker activate

Step 3 is the part that surprises people. A hard refresh reloads the page; it does not release the worker.

Check what state you're in

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
});

Force the handover

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.
    }
  });
});

Getting yourself unstuck right now

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.

Prevention

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.