Service Workers Serving Stale Cache After a Production Deploy: How to Fix It
You deployed a hotfix an hour ago. The monitoring dashboard shows the new code is live. But half your users are still hitting the bug you just patched β and every one of them insists they've refreshed the page. Your service worker is lying to them, and to you.
Stale service worker cache is one of those problems that only bites you in production, exactly when you can least afford it. Understanding the lifecycle is the key to stopping it for good.
What You'll Learn
- How the service worker lifecycle causes updates to silently stall
- The difference between cache versioning and cache-busting strategies
- How to use
skipWaitingandclients.claimcorrectly and safely - How to notify users when a new version is ready
- How Workbox handles most of this for you automatically
Why Your Service Worker Is Ignoring Your Latest Deploy
A service worker sits between the browser and the network. Once installed, it intercepts every fetch request and can respond from cache without touching your server. That's great for performance β until you push new code and the worker keeps serving the old version.
The confusion usually comes down to one word: waiting. When the browser detects a new service worker file, it installs it but holds it in a "waiting" state. The old worker stays in control until every tab running your site is closed. If a user keeps one tab open for hours (which is completely normal), they will never see your update.
How the Service Worker Lifecycle Works Against You
The lifecycle has three phases: installing, waiting, and active. The browser checks for a new service worker script roughly every 24 hours, or whenever you navigate to a controlled page. If the script has changed by even one byte, the browser starts the install phase for the new worker.
Here's the trap: the new worker finishes installing but cannot become active while any old-controlled client (a browser tab) is still open. It parks in the waiting phase indefinitely. This is a safety mechanism β browsers don't want two versions of your app fighting over the same tabs β but it means deployments don't propagate until users naturally close every tab.
Most developers don't realize this until they're staring at a production incident. If you've also been puzzled by environment-specific behavior, the same kind of phase-based execution is behind React useEffect firing differently in dev versus production.
The Two Main Causes of Stale Cache
1. The service worker script itself is cached by the HTTP layer
Your CDN or server might be caching service-worker.js with a long Cache-Control max-age. The browser never even sees the updated script because the network layer hands it the old one. Always serve the service worker file itself with Cache-Control: no-cache or max-age=0. This is a separate concern from the assets the worker caches internally.
2. The worker's internal cache still holds old asset URLs
Even if the browser picks up the new worker script, the worker might open the same cache store name and skip re-fetching assets because the URLs look identical. If you're not changing the cache name or using content-hashed filenames, the worker happily returns the old bytes.
Cache Versioning: The First Line of Defense
The most reliable low-tech fix is to version your cache store name. When the new worker activates, it deletes all caches whose names don't match the current version and re-fetches everything fresh.
const CACHE_NAME = 'my-app-v3'; // bump this on every deploy
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll([
'/',
'/index.html',
'/main.abc123.js', // content-hashed filenames help too
'/styles.def456.css',
]);
})
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
})
);
});
Pair this with content-hashed filenames from your bundler (Webpack, Vite, esbuild all do this out of the box). When the file content changes, the filename changes, so the worker is forced to fetch the new file even if the cache name stays the same. The two strategies together are nearly bulletproof.
Using skipWaiting and clients.claim to Force Updates
Versioned caches solve the asset freshness problem, but the worker still waits for all tabs to close before it becomes active. If you want the new worker to take over immediately β without waiting β call skipWaiting() inside the install event handler.
self.addEventListener('install', event => {
self.skipWaiting(); // don't wait β activate immediately
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
);
});
self.addEventListener('activate', event => {
event.waitUntil(
Promise.all([
// claim all open clients immediately
clients.claim(),
// clean up old caches
caches.keys().then(names =>
Promise.all(
names
.filter(n => n !== CACHE_NAME)
.map(n => caches.delete(n))
)
),
])
);
});
clients.claim() tells the newly active worker to take control of all open tabs right now, not just future navigations. Together, skipWaiting + clients.claim means your new code is live for everyone within seconds of the browser detecting the updated script.
One real risk: if a user has an in-flight form submission or is mid-checkout when the worker swaps, they could hit a mismatch between the old page HTML (still in the browser) and the new API responses. Evaluate whether your app can tolerate this. A notification-based approach (described below) is safer for apps with complex user state.
Communicating Updates to the User
For apps where a sudden worker swap would be jarring, the better pattern is to detect the waiting worker and show a prompt: "A new version is available β reload to update." This gives users control and avoids mid-session disruptions.
// In your main application JS
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js').then(registration => {
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
// A new worker is waiting β tell the user
showUpdateBanner();
}
});
});
});
}
function showUpdateBanner() {
const banner = document.getElementById('update-banner');
banner.hidden = false;
document.getElementById('reload-btn').addEventListener('click', () => {
navigator.serviceWorker.getRegistration().then(reg => {
if (reg && reg.waiting) {
// Signal the waiting worker to skip waiting
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
window.location.reload();
});
}
In the service worker, listen for that message and call skipWaiting only when the user asks for it:
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
This is the pattern most production PWAs use. It's explicit, it's safe, and it respects user context. Think of it the same way you'd approach errors that only surface in production β the correct fix requires understanding what the user actually experiences, not just what the dev console shows.
Workbox: Cache Busting Without the Boilerplate
If you'd rather not hand-roll all of this, Workbox (Google's service worker library) automates most of it. Workbox generates a precache manifest β a list of your assets with their content hashes β at build time. On deploy, only the assets that actually changed get re-fetched. The versioning, cleanup, and manifest diffing all happen automatically.
// A minimal Workbox-based service worker (using workbox-sw CDN build)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// self.__WB_MANIFEST is injected by workbox-webpack-plugin or vite-plugin-pwa
// Runtime caching for API calls
workbox.routing.registerRoute(
({ request }) => request.destination === 'fetch' && request.url.includes('/api/'),
new workbox.strategies.NetworkFirst({
cacheName: 'api-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({ maxAgeSeconds: 60 }),
],
})
);
Workbox also ships a workbox-window package that handles the update detection and messaging pattern from the previous section. For a new project, starting with vite-plugin-pwa or workbox-webpack-plugin is probably the fastest path to a correct, production-ready setup.
Common Pitfalls That Keep Users on Old Versions
Serving the worker file with a long cache TTL
Set Cache-Control: no-store specifically for your service-worker.js (and any sw.js entry point). Your CDN config or server should treat this file differently from the rest of your static assets. Everything else can have long TTLs because the filenames are hashed anyway.
Forgetting to update the cache name or manifest
If you automate deployments, automate the cache version bump too. A build script that injects CACHE_NAME = 'app-' + GIT_COMMIT_SHA is simple and reliable. Manual version bumps get forgotten under pressure β exactly the moment they matter most.
Not handling the activate event properly
Failing to call event.waitUntil() around your cache cleanup in the activate handler means the browser may cut cleanup short. Always wrap async work in waitUntil so the browser knows to keep the worker alive until the work completes.
Testing with DevTools cache disabled
"Update on reload" in Chrome DevTools bypasses the waiting phase entirely. That's useful for development, but it means you won't catch lifecycle bugs until production. Test the real update flow by opening your app in a normal tab, deploying, opening a second tab, and observing what the first tab does. This kind of environment mismatch is a recurring theme β it's the same reason fetch behavior can differ between local and live environments.
Registering the worker with a scope that's too broad or too narrow
The worker's scope defaults to the directory the script sits in. A worker at /sw.js covers your whole origin. A worker at /app/sw.js only covers /app/. Mismatched scopes mean some navigations are controlled and others aren't, which produces inconsistent caching behavior that's genuinely hard to diagnose.
Relying on HTTP redirects for your service worker URL
If /sw.js redirects to /dist/sw.js, the browser may reject the registration or treat the scope incorrectly. Serve the worker directly at a stable URL with no redirect chain. This is also worth reviewing alongside how you handle security-sensitive resources that need precise cache and header control.
Next Steps
If you're dealing with stale cache right now, here's a concrete action list:
- Check your service worker HTTP headers first. Open DevTools β Network, filter for
service-worker.js, and confirm the response hasCache-Control: no-storeorno-cache. Fix this before anything else. - Version your cache store name by injecting the current git commit hash at build time. Update your activate handler to delete all caches with non-matching names.
- Add a user-facing update prompt using the
updatefound+postMessagepattern above. This is the right default for production apps with user sessions. - Adopt Workbox if you're maintaining more than a handful of cached assets. The precache manifest + build plugin combination handles versioning and cleanup automatically and is well-tested in production.
- Test the real update flow in a browser with DevTools closed, using two tabs, to verify your strategy works the way users experience it β not the way DevTools shortcuts make it look.
Frequently Asked Questions
Why are users still seeing old content after I deployed a fix, even after they refresh?
A service worker in the 'waiting' state is most likely the cause. The browser installs the new worker but keeps the old one active until all tabs running your site are closed. A page refresh alone does not clear the waiting worker β the old worker continues serving cached assets.
How do I force a service worker to update immediately without users closing all tabs?
Call skipWaiting() inside the install event and clients.claim() inside the activate event of your new service worker. This makes the new worker take control of all open tabs immediately after installation, without waiting for a natural tab close cycle.
Should I use skipWaiting automatically or prompt the user to reload?
Prompting the user is safer for most production apps. Automatic skipWaiting can disrupt users mid-session if the new worker's cached assets no longer match the HTML already loaded in the browser. The postMessage pattern β showing a banner and letting users choose when to reload β is a better default.
How do I prevent my CDN from caching the service worker script file itself?
Set Cache-Control: no-store or Cache-Control: max-age=0 on the service worker file specifically (typically sw.js or service-worker.js). Most CDNs let you set per-path cache rules; apply a short or zero TTL to that path while leaving long TTLs on your hashed static assets.
Does Workbox automatically handle cache versioning and cleanup on deploy?
Yes. Workbox generates a precache manifest at build time that includes a content hash for each asset. On the next deploy, only assets whose hash has changed are re-fetched, and old caches are cleaned up during the activate phase. Pairing workbox-webpack-plugin or vite-plugin-pwa with workbox-window covers versioning, cleanup, and update notification in one setup.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!