Symptom
A site was migrated from a Vue 2 / Nuxt 2 SPA to a different framework that does not include a Service Worker. The deployment succeeded, CDN caches were purged, and hitting the new URL directly returned the new HTML.
However, shortly after the migration went live, asymmetric behavior was reported:
| Access path | Content displayed |
|---|---|
| A browser that had previously opened the old site | Old HTML (pre-migration) |
| Incognito window in the same browser | New HTML |
| Different device or different browser | New HTML |
| Verification environment on a separate domain hitting the same backend | New HTML |
Server-side logs showed very few requests from returning visitors. Something in the browser was "intercepting" responses before they arrived. This is the classic symptom of a Service Worker (SW) cache-first strategy in effect.
Root Cause
The old site had @nuxtjs/pwa enabled, which registered a workbox-generated SW at /sw.js. The default navigation request strategy in @nuxtjs/pwa is NetworkFirst (falling back to cache only when offline), but build artifacts (hashed JS / CSS / images under /_nuxt/*) are precached and served CacheFirst. The moment the SW is installed, these assets are burned into Cache Storage.
Even with NetworkFirst for navigation, network timeouts or transient failures immediately fall through to stale cached HTML, and precached bundles keep returning the old versions. The result:
- The SW intercepts
fetchevents - Navigation is NetworkFirst, but precached assets are CacheFirst
- Depending on conditions, cached responses are served, making it appear to users that "the site hasn't updated"
Even if the server's HTML is replaced, it either doesn't reach the browser, or if it does, it is combined with stale JS in ways that produce unintended behavior.
Why This Happens Often with Nuxt 2
@nuxtjs/pwa was a convenient module that enabled PWA functionality with a single line added to nuxt.config.js. It bundled four sub-modules โ manifest, meta, icons, and workbox โ and workbox was on by default with no configuration required. SW generation, registration, and caching strategies were all wired in implicitly.
Nuxt 2 itself reached EOL on 2024-06-30, and the @nuxtjs/pwa of that era was never ported to Nuxt 3; its last release was v3.3.5 (January 2021). Its spiritual successor for Nuxt 3 and later is the separate @vite-pwa/nuxt.
The picture is different with more recent frameworks:
| Framework | SW behavior |
|---|---|
Nuxt 2 + @nuxtjs/pwa | workbox-enabled SW auto-registered by default (autoRegister: true) |
Nuxt 3/4 + @vite-pwa/nuxt | The module itself is opt-in. When included, it generates and registers an SW by default, but it is not part of the Nuxt standard. |
| Next.js (App Router) | No SW by default. Serwist (@serwist/next) or similar must be added explicitly (the approach recommended in Next.js official documentation). |
| SvelteKit / Astro | No SW generated by default. |
In short, migrating from Nuxt 2 to any of the frameworks above means the migration target has no mechanism to generate an SW by default, so /sw.js is simply no longer produced. You think "we're done with PWA," but inside the user's browser the Nuxt 2-era SW is still running โ an asymmetry that persists invisibly.
Note:
next-pwa(the shadowwalker fork), commonly used in Next.js, stopped receiving updates in late 2022. It was followed by the@ducanh2912/next-pwafork, and today Serwist is the option cited in the Next.js official documentation.
Why It Doesn't Die on Its Own
The intuitive response is "just delete /sw.js" โ but this alone is not enough to retire the SW. Two points from the Service Worker specification are relevant:
- An SW is persisted within the same origin, and survives browser restarts and system reboots. It only disappears if the user explicitly clears "browsing data โ site data," the server returns a
Clear-Site-Data: "storage"header, or the browser's LRU eviction runs due to storage pressure. - The browser fetches
/sw.jsagain to check for updates whenever navigation occurs within the SW's scope. (Historically, the spec capped HTTP cachemax-ageat 24 hours for SW scripts. Since Chrome 68, the defaultupdateViaCache: 'imports'means the top-level SW script is not subject to the HTTP cache at all during update checks โimportScripts()dependencies still are.) In any case, unless a new fetch of the script returns200 OKwith a JavaScript MIME type, the browser preserves the existing SW. A 404 or an HTML response causes the update check to pass through without installing anything new.
In other words, with the old /sw.js deleted, the old SW in the user's browser keeps running as-is. Purging the CDN, switching DNS โ the logic lives in the SW runtime, not the browser cache, so server-side interventions cannot reach it.
Solution: Kill-Switch Service Worker
The only option is to serve a SW that unregisters itself and clears all Cache Storage, at exactly the same URL as the old SW. This is called a kill-switch SW, or a self-destructing SW.
When the old SW runs its update check, it fetches this new script, which goes through install โ activate, and finally calls registration.unregister() to remove itself.
// /sw.js โ kill-switch Service Worker
// Exists solely to retire the old PWA SW. Creates no new caches.
self.addEventListener('install', (event) => {
// Activate the new SW immediately without waiting for open tabs to close
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
// 1. Delete all Cache Storage (including workbox-precache-* and everything else)
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
// 2. Unregister this SW itself
// After this point, no SW is associated with this origin
await self.registration.unregister();
// 3. Hard-reload any open tabs so they fetch the new site
// directly from the network, bypassing the SW.
// Without `includeUncontrolled: true`, the new SW has not yet
// taken control of any client (it only called skipWaiting(),
// not clients.claim()), so the array would be empty by default.
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
for (const client of clients) {
// navigate() only works for same-origin; ignore others with try/catch
try { client.navigate(client.url); } catch (_) { /* noop */ }
}
})());
});
// It is important NOT to register a fetch handler.
// Without one, no requests are intercepted, and after the reload
// all requests flow directly to the new site.
Three key points:
- Call
skipWaiting()ininstall. Without it, the new SW won't become active until all open tabs are closed, leaving users in a "nobody knows when this will apply" state. - Do not register a
fetchhandler. Registering one would cause the new SW to start intercepting requests, defeating the purpose of retiring the SW entirely. - Call
client.navigate(client.url)to re-navigate open tabs.unregister()alone leaves currently open tabs still controlled by the old SW; users would have to reload manually. Also, passincludeUncontrolled: truetoclients.matchAllโ the new SW has just been activated viaskipWaiting()and hasn't taken control of any tab yet, so the default (includeUncontrolled: false) would return an empty array, causing the re-navigation to silently do nothing.
Deployment Procedure
- Place the script above at exactly the same URL as the old SW (e.g.,
/sw.js). A single-character difference in the path means the old SW will never fetch the update. - Confirm that the response
Content-Typeisapplication/javascript(ortext/javascript) and the status is200 OK. If a 404 or HTML fallback page is returned instead, the browser will not install the new script as a SW. - If a CDN is in front, check whether the old
/sw.js404 response was cached by the CDN and invalidate it if necessary. - Set
Cache-Controlshort. See below.
Pitfalls
/sw.js Is Returning an HTML 404
The migration target framework doesn't know about the /sw.js path, so it may serve the SPA fallback index.html instead. In this case:
- Status: 200 OK
- Content-Type:
text/html
The browser judges "this is not JS, installation failed" and preserves the old SW. The kill-switch script must be placed as a physical static file first.
Cache-Control Set Too Aggressively
If /sw.js itself is being served with Cache-Control: max-age=31536000 or similar long-lived caching, the HTTP cache may hold on to the old version even during an update check, depending on the update path. When placing a kill-switch SW, it is safer to set the /sw.js response to something like Cache-Control: max-age=0, must-revalidate.
Note: for top-level SW scripts, Chrome 68's default updateViaCache: 'imports' means the HTTP cache is not consulted during update checks at all (importScripts() dependencies are still subject to it, as the name implies). That said, older registrations โ such as those from versions of @nuxtjs/pwa that used updateViaCache: 'all' โ may still be affected by HTTP cache, so relaxing Cache-Control retains some value.
Don't Remove the Kill-Switch Too Soon
Even after the retirement process completes, keep the kill-switch in place for several weeks to a few months. The reason is straightforward: long-tail returning users (people who haven't visited in months) still have the old SW running in their browser. When they first return, the kill-switch SW needs to be fetchable at that moment. If /sw.js is gone (404) at that point, the retirement process never fires.
Keep it in place until you can reasonably conclude the population has been reached โ for example, until access logs show zero requests that appear to be routed through the old SW.
Lessons Learned
- A registered Service Worker does not disappear by itself when the framework is replaced. This was the core of the issue.
- Modules like
@nuxtjs/pwathat deliver "one line to PWA" are convenient, but the cost of removing them is disproportionate to the ease of adding them. - Framework migration checklists should always include "plan for retiring the old PWA Service Worker" โ at the same level of importance as redirects, rewrites, SEO, and sitemaps.
- A kill-switch SW is technically a ~30-line file that does nothing, but it is the only reliable way to retire a previously registered SW. The substance is less in writing the code and more in operational discipline: place it at the same URL, serve it as
200/application/javascript, and leave it there long enough.

Comments
โฆ