The problem
You open http://localhost:3000 and get a different project, one you built weeks ago, even though your current dev
server is running and the old one isn't.
A service worker is registered per origin, and localhost:3000 is the same origin for every project that has ever run
on that port. If one of them was a PWA, its worker is still installed in your browser. Workbox's navigateFallback
answers every page load with the cached index.html without touching the network, so requests never reach your dev
server.
Typical source: building a PWA (for example with vite-plugin-pwa) and serving the production build on a common port
like 3000 or 8080.
Find every registered worker
Chrome, Edge, Arc, Brave: open chrome://serviceworker-internals. It lists every registration in the profile with
its scope and script URL. Search the page for localhost.
Firefox: open about:serviceworkers.
From the shell (Chrome, read-only): the cached worker scripts live in the profile folder, so you can tell which project a worker belongs to:
cd ~/Library/Application\ Support/Google/Chrome/Default/Service\ Worker # or "Profile 1", etc.
rg -a -l 'localhost:3000' ScriptCache
strings -a ScriptCache/<file> | rg -o 'https?://localhost:3000[^ "]*'
A hashed file name such as workbox-2fbc6a65.js can be matched against the dist/ folders of your projects. Don't
delete files in this folder while the browser is running, since that can corrupt the profile.
Remove it
Pick one:
-
Unregister: on
chrome://serviceworker-internals, find the entry with scopehttp://localhost:3000/and click Unregister. -
Clear everything for the origin: open
http://localhost:3000, then DevTools → Application → Storage → Clear site data. This also removes the precache in Cache Storage. -
Console, on the affected origin:
(await navigator.serviceWorker.getRegistrations()).forEach((r) => r.unregister()); (await caches.keys()).forEach((k) => caches.delete(k));
Hard-reload afterwards. In Safari, remove localhost under Settings → Privacy → Manage Website Data.
Remove it for everyone (kill switch)
If other people or other browsers have the stale worker, a page can't unregister it for them, because the old worker
intercepts the page. The browser does still fetch the worker script from the network when it checks for updates. Serve
this at the same path as the old script (for example /sw.js) and it replaces the old worker, then removes itself:
// sw.js: replaces a stale worker, deletes its caches and unregisters.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
await self.registration.unregister();
const windows = await self.clients.matchAll({ type: "window" });
windows.forEach((client) => client.navigate(client.url));
})(),
);
});
The script URL has to match the registered one exactly. You can read it from chrome://serviceworker-internals.
