You deploy a critical fix. CDN cache is purged. Browser hard-refresh shows the new version. Users on Slack still report seeing the old broken page. Some see it for hours. A few diehards see it for days. The culprit is almost always a service worker installed by your PWA, Workbox, or Next.js / Astro PWA plugin: it intercepted fetches before the network, served cached index.html, and that cached HTML references old hashed JS chunks that may not even exist on the server anymore (cue the blank white screen). This is one of the most common “deployed but users see old” bugs, and it needs both a deploy-time fix and a runtime escape hatch for users already trapped.
Fastest fix (if you just need it gone now): deploy a self-destructing no-op sw.js at the exact same URL as the broken one (Step 4 below). It unregisters the old worker and reloads every controlled tab on next visit. Then ship your real fix with HTML on NetworkFirst, skipWaiting + clientsClaim, and sw.js served with Cache-Control: max-age=0.
Which bucket are you in?
Match your symptom to the cause before you change any code.
| Symptom you see | Most likely cause | Jump to |
|---|---|---|
| Old HTML loads forever, no errors | HTML cached CacheFirst (cause 1) | Step 1 |
| New SW listed as “waiting”, never activates | No skipWaiting() (cause 2) | Step 2 |
Blank page + 404 on a /assets/*.js chunk | Cached HTML points at a deleted hash (cause 3) | Step 1 + Step 4 |
| New SW “activated” but old one still controls tab | No clients.claim() (cause 5) | Step 2 |
curl -I .../sw.js shows long max-age | CDN cached sw.js itself (cause 6) | Step 3 |
Console: Failed to register a ServiceWorker | Install failed in prod (cause 7) | Step 7 |
| Users stuck for days/weeks, nothing else works | Trapped clients, esp. iOS Safari | Step 4 |
Common causes
Ordered by what causes most user reports.
1. Service worker uses CacheFirst for index.html
Workbox / vite-plugin-pwa / next-pwa default strategies sometimes cache HTML with CacheFirst. Once cached, the SW serves it forever from cache — the user never sees new HTML referencing new JS chunk hashes.
How to spot it: DevTools → Application → Service Workers → check the registered SW source. CacheFirst on navigationRoute or HTML routes is the smoking gun.
2. New SW registered but skipWaiting() not called
Browsers install the new SW silently. It sits in waiting state until all tabs of the site close (which for productivity tools may be days). The old SW keeps controlling pages until then.
How to spot it: DevTools → Application → Service Workers shows “waiting to activate” or two SW versions listed. Page banner asking “reload to update” never fires.
3. Cached chunks reference hashes that no longer exist
Workbox’s precache manifest from build N points at /assets/index.abc123.js. Build N+1 hashes it /assets/index.def456.js. SW serves cached index.html referencing abc123 — but the server returns 404. Result: blank page.
How to spot it: User sees blank page; DevTools Network shows 404 for a JS chunk; the SW Application tab shows precached entries pointing at the missing hash.
4. Cache scope set to root and never cleared
navigator.serviceWorker.register('/sw.js', { scope: '/' }) controls every URL under the root. If your SW was installed months ago and you have since changed paths / removed routes, the SW still claims them.
How to spot it: Routes that should 404 instead return cached responses. User has a SW registered from a previous version of the site that hasn’t been updated.
5. No clients.claim() in activate handler
Even after activation, the new SW doesn’t control already-open tabs without clients.claim(). Existing tabs continue with the old SW until reloaded.
How to spot it: New SW shows as “activated” in DevTools but old SW is still controlling the page. Refresh once and the new one takes over.
6. CDN caching sw.js itself with long max-age
If sw.js is served with Cache-Control: max-age=31536000, the browser hits its own HTTP cache for the SW and never sees the new one. The SW update lifecycle never starts.
How to spot it: curl -I https://your-site/sw.js shows long max-age. Browser DevTools Network tab shows sw.js from disk cache rather than network.
7. SW registered conditionally and fails to install on production
if (window.location.hostname !== 'localhost') registerSW() — if your SW only registers in production, a bug in production SW install (e.g., missing precache file) silently fails and the page works fine but offline support / fast loading is gone.
How to spot it: DevTools Console shows Failed to register a ServiceWorker or Service worker installation failed.
Before you start
- Confirm a service worker is actually installed: DevTools → Application → Service Workers, check for registered SW.
- Identify which library installed it: Workbox, next-pwa, vite-plugin-pwa, custom code.
- Capture a sample affected user’s browser console / network tab if possible.
- Know what your current cache strategy is per route type (HTML vs. JS vs. images).
- Have ability to deploy a new SW immediately as part of the fix.
Information to collect
- Library + version used for SW generation (
@vite-pwa/astro,workbox-webpack-plugin,next-pwa). - Cache strategies configured per route (
runtimeCachingarray in config). Cache-Controlheader onsw.jsitself.- Whether
skipWaiting()andclients.claim()are configured. - A list of affected user agents / browsers (mostly Chrome desktop and Safari iOS for PWAs).
- Most recent deploy’s precache manifest contents.
Step-by-step fix
Ordered: stop the bleeding for new users first, then evict trapped users.
Step 1: Switch HTML to NetworkFirst immediately
In your SW config:
// workbox / vite-plugin-pwa
runtimeCaching: [
{
urlPattern: ({ request }) => request.mode === "navigate",
handler: "NetworkFirst",
options: {
cacheName: "html-cache",
networkTimeoutSeconds: 3,
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 },
},
},
],
New deploys will fetch fresh HTML from the network, falling back to cache only if offline. Static JS / CSS chunks can stay CacheFirst because they have hashed filenames.
Step 2: Add skipWaiting and clientsClaim
In the SW source (or config):
// vite-plugin-pwa
VitePWA({
registerType: "autoUpdate",
workbox: {
clientsClaim: true,
skipWaiting: true,
cleanupOutdatedCaches: true,
},
});
Note: as of vite-plugin-pwa (June 2026), registerType: "autoUpdate" already forces clientsClaim and skipWaiting to true for you, so the two lines above are belt-and-suspenders, not strictly required. The one worth adding explicitly is cleanupOutdatedCaches: true, which calls workbox.precaching.cleanupOutdatedCaches() so old precache entries (the deleted hashes from cause 3) get purged on activation instead of lingering and serving 404s. If you use registerType: "prompt" instead, you must call skipWaiting() yourself only when the user confirms (see Step 6).
Or in custom sw.js:
self.addEventListener("install", (event) => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
Caveat: skipWaiting on a busy session can cause a momentary version mismatch between open tabs and the new SW. Tradeoff is fine for content sites; for apps with critical in-progress state, prompt the user instead.
Step 3: Prevent sw.js itself from being cached long
In Vercel/Netlify headers config:
// vercel.json
{
"headers": [
{
"source": "/sw.js",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" },
{ "key": "Service-Worker-Allowed", "value": "/" }
]
}
]
}
The SW file itself MUST be fetched fresh every navigation; otherwise update never happens. Browsers special-case sw.js somewhat but explicit headers remove all ambiguity.
Step 4: Ship a kill switch for trapped users
Deploy a SW that immediately uninstalls itself for users who are stuck on a broken version:
// public/sw.js — emergency uninstall, served at the SAME url as the broken sw
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
// 1. drop every cache this origin owns
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
// 2. unregister this worker
await self.registration.unregister();
// 3. force-reload every tab it still controls so they fetch real html
const clients = await self.clients.matchAll({ type: "window" });
clients.forEach((c) => c.navigate(c.url));
})());
});
// no fetch handler on purpose: requests pass straight to the network
The single most important rule, per Chrome’s “Removing buggy service workers” guide: serve this file at the exact same URL the broken worker was registered at (usually /sw.js). If you publish the uninstall worker at a new path, the browser keeps running the old buggy worker alongside it and nothing changes. This is also why you should register your SW at an unversioned, stable filename (/sw.js, not /sw-a1b2c3.js) so you always have a fixed address to overwrite.
Two notes on the snippet above:
- Inside a service worker, use
self.registration.unregister()(notnavigator.serviceWorker.getRegistrations(), which is a window API and is not reliably available in the worker scope). - Omitting a
fetchlistener is deliberate. A worker with no fetch handler lets every request pass through to the network as if no SW were installed, so even before the tab reloads the user is no longer served from cache.
Deploy this temporarily. Any user with an active SW gets a self-uninstall on next page load, the controlled tab reloads, and they see real HTML again. After 1-2 weeks (long enough for iOS Safari stragglers, see the FAQ), replace it with your normal, fixed SW at the same URL.
Step 5: Bump the SW filename or version to force re-registration
Browsers re-check sw.js byte-by-byte. If even one byte differs, install kicks in. To force this on every deploy:
// At the top of sw.js
const BUILD_ID = "__BUILD_ID__"; // replaced at build with commit SHA
Build step:
sed -i "s/__BUILD_ID__/$(git rev-parse --short HEAD)/g" dist/sw.js
Every deploy now produces a byte-different sw.js, triggering update flow.
Step 6: Add an in-page update banner
For users who do not have skipWaiting enabled but a new SW is waiting:
// register-sw.ts
import { registerSW } from "virtual:pwa-register";
const updateSW = registerSW({
onNeedRefresh() {
if (confirm("New version available. Reload?")) updateSW(true);
},
});
Visible prompt converts “trapped users” into one-click recoveries without requiring the kill switch.
Step 7: Audit Service-Worker-Allowed and scope
If your SW lives at /static/sw.js but you want it to control /:
Service-Worker-Allowed: /
Without that header, the SW only controls /static/... paths, which is rarely what you want. See robots txt not working for related header-delivery debug patterns.
Verify
- New deploy: clear browser SW + cache, load site, check Application → Service Workers shows the new SW activated immediately.
curl -I https://your-site/sw.jsshowscache-control: max-age=0.- DevTools Network shows fresh HTML on every navigation, not “(from ServiceWorker)” with old content.
- For trapped users: have someone test the kill-switch SW and confirm a single refresh recovers them.
- After 1 week, no Slack reports of “still seeing old page”.
Long-term prevention
- Default HTML / navigation requests to
NetworkFirstwith a shortnetworkTimeoutSeconds; neverCacheFirstHTML. - Always pair
skipWaitingwithclientsClaim; one without the other leaves stale clients. - Turn on
cleanupOutdatedCaches: trueso old precache versions (and their dead chunk hashes) get evicted on every activation. - Register the SW at a stable, unversioned URL (
/sw.js), never a hashed filename, so you always have a fixed address to overwrite with a kill switch. - Keep
sw.jsunderCache-Control: max-age=0in your hosting config so the update lifecycle can never stall on an HTTP-cached worker. - Embed a build ID / commit SHA in SW source so every deploy forces a byte change.
- Always ship an update banner so users have a way out without a kill switch.
- Keep an emergency “uninstall SW” branch ready to deploy; you will need it eventually.
For the underlying lifecycle, MDN’s “Using Service Workers” and the Workbox caching-strategies overview are the canonical references.
Common pitfalls
- Disabling SW entirely “to fix the bug” without considering users with existing SW installs — they remain broken until you ship a SW that uninstalls itself.
- Using
CacheFirstfor HTML “for speed” — saves 200ms on first load, costs you a week of confused users on every deploy. - Forgetting that iOS Safari has aggressive SW survival; an iPhone user can sit on a stale SW for weeks without triggering an update. See vercel 500 errors for related browser-state debugging.
- Calling
skipWaiting()for an app with in-progress state — users lose unsaved edits when the new SW takes over mid-action. - Versioning the SW by filename (
sw-v2.js) but never deregistering the old one — both run, behavior gets weird.
FAQ
Q: How do I detect if a user is stuck on a stale SW from server logs?
Look at user agent + version of static assets requested. If a user is requesting a chunk hash that exists in build #88 while you are on build #112, they are stuck. Log a synthetic header x-build-id and compare to current.
Q: Should I just remove the service worker entirely?
If you don’t need offline support, fast repeat-visit loads from cache, or push notifications: yes. Most content sites don’t need a SW. Keep one only if it earns its complexity.
Q: My users on Safari iOS see the issue more. Why?
iOS Safari is more aggressive about keeping SWs alive between sessions and is slower to honor skipWaiting(). The kill-switch SW is especially important for iOS.
Q: Will the kill switch break a working PWA install?
It uninstalls the SW. The next time the user visits and you have a working SW deployed, the install lifecycle starts fresh. The “Add to Home Screen” shortcut still works; PWA features are restored. See static site blank page for related symptom debugging.
Q: How long until every trapped user is actually recovered?
A user only recovers the next time they open the site, because the uninstall worker runs on that navigation. Active daily users clear within a day; weekly visitors take up to a week. iOS Safari can hold a worker across sessions, so leave the kill switch live for 1-2 weeks before swapping it for your normal SW. Confirm progress by watching for old chunk-hash requests in your server logs dropping to zero rather than guessing.
Q: I deployed the uninstall worker but some users are still stuck. What did I miss?
Almost always one of two things: (1) the uninstall sw.js is served at a different URL than the broken worker, so the old one keeps running alongside it (it must be the exact same path), or (2) your CDN is still serving the old sw.js from cache because it lacks Cache-Control: max-age=0. Run curl -I https://your-site/sw.js and confirm both the new body and a no-cache header before assuming the worker is wrong.
Tags: #Troubleshooting #service-worker #pwa #cache #Deployment