What this usually means
Service workers rely on the `install` and `activate` lifecycle events to update caches. The most common cause of stale content is a failure in cache versioning: the new SW installs, but the `activate` event doesn't delete old caches, so the fetch handler continues serving from the old cache. Alternatively, the SW may not call `skipWaiting()` or `clients.claim()`, leaving old pages still controlled by the previous SW. Another subtle cause: the SW update check happens only every 24 hours by default, so a deployment may go unnoticed for a day unless you force an update via `navigator.serviceWorker.register()` with a changed script URL or use `Update` in DevTools.
The first ten minutes — establish facts before touching code.
- 1Open DevTools > Application > Service Workers and check the status: is the new SW in 'waiting' state?
- 2In the Network tab, look for responses marked '(from ServiceWorker)' and verify the content matches the latest build.
- 3Force an update: click 'Update' on the SW in DevTools, or run `navigator.serviceWorker.register('/sw.js')` and check if the 'waiting' SW becomes 'active'.
- 4In the Cache Storage pane, list all caches. Look for multiple cache versions (e.g., 'myapp-v1', 'myapp-v2') and check which one the SW is using.
- 5Add a console.log in the SW `fetch` event to see which cache it reads from. Reload and check the SW's console output.
- 6Check the `install` and `activate` event listeners in the SW source. Ensure `skipWaiting()` is called in `install` and `clients.claim()` in `activate`, and that old caches are deleted in `activate`.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchDevTools > Application > Service Workers – shows SW lifecycle state
- searchDevTools > Application > Cache Storage – lists all caches and their contents
- searchNetwork tab – response headers; look for `x-service-worker-cache: hit` or similar custom headers
- searchSW source file (e.g., `/sw.js`) – inspect `install`, `activate`, and `fetch` event handlers
- searchBuild output: check if the SW file itself is cache-busted (e.g., `sw.js` vs `sw.abc123.js`)
- searchServer logs: look for SW update requests (HTTP 304 or 200 for `/sw.js`)
- searchConsole (SW context) – use `self.addEventListener('message', ...)` to log cache state
Practical causes, not theory. These are the things you will actually find.
- warningSW update is delayed by the 24-hour browser check; users don't get the new SW until next day
- warning`activate` event doesn't delete old caches, so fetch handler continues serving stale assets
- warningMissing `skipWaiting()` in `install` – new SW stays in 'waiting' state indefinitely
- warningMissing `clients.claim()` in `activate` – existing pages remain controlled by old SW
- warningCache key collision: using the same cache name across deployments, so old assets are never evicted
- warningSW file is cached by a CDN or HTTP cache, so the browser never sees the updated script
- warningService worker scope too narrow – the SW doesn't control the pages that need updating
Concrete fix directions. Pick the one that matches your root cause.
- buildImplement cache versioning: `const CACHE = 'myapp-v' + new Date().getTime();` and delete all other caches in `activate`.
- buildCall `self.skipWaiting()` in the `install` event to activate the new SW immediately.
- buildCall `clients.claim()` in the `activate` event to take control of all open pages.
- buildAdd a cache-first strategy but with a network-update background: serve from cache, then fetch and update cache for next time.
- buildForce SW update on each page load: register the SW with a unique URL or query param (e.g., `navigator.serviceWorker.register('/sw.js?v=' + Date.now())`).
- buildUse `Cache-Control: no-cache` on the SW script response to prevent HTTP caching.
- buildAdd a version number in the SW script itself (e.g., `const SW_VERSION = '1.2.3';`) and compare it during `message` events to trigger updates.
A fix you cannot prove is a guess. Close the loop.
- verifiedAfter fix, open a new tab to the site, check DevTools > SW – the SW should show 'activated and is running' immediately.
- verifiedReload the page multiple times; Network tab should show new assets with (from ServiceWorker) and correct content.
- verifiedClear all caches manually in DevTools, then reload – the SW should repopulate with new assets.
- verifiedCheck the Cache Storage: only one cache named with the new version should exist.
- verifiedDeploy a small change (e.g., update a version string) and verify that users see the update within minutes without manual refresh.
- verifiedUse `navigator.serviceWorker.controller.postMessage({type: 'GET_VERSION'})` and log the response to confirm the active SW version.
Things that make this bug worse or harder to find.
- warningDon't use a fixed cache name like `'myapp-cache'` – version it or use a hash.
- warningDon't forget `skipWaiting()` and `clients.claim()` – they're not automatic.
- warningDon't cache the service worker file itself with a long max-age – use `Cache-Control: no-cache`.
- warningDon't assume a hard refresh bypasses the SW – it may still serve from cache. Use 'Empty Cache and Hard Reload'.
- warningDon't ignore the waiting state – if you see a waiting SW, you need to trigger the activate lifecycle.
- warningDon't blindly delete all caches in `activate` without checking that your current cache is ready – you might delete it before it's populated.
Deployed new React app but users see the old one for 24 hours
Timeline
- 09:00Deployed v2.3.1 of React app with new dashboard UI.
- 09:15User reports they still see old UI. I check – my own browser shows new UI.
- 09:30Checked DevTools SW tab: old SW still active, new one waiting.
- 09:35Clicked 'Update' – new SW activates, UI updates. But user says it's still old.
- 09:50Checked Cache Storage: two caches – 'myapp-v2' and 'myapp-v3'. SW serving from v2.
- 10:00Looked at SW source: `activate` event only logs 'activated', doesn't delete old caches.
- 10:15Fixed: added cache deletion logic in activate, incremented cache version.
- 10:30Deployed fix. Confirmed new SW activated immediately on my machine.
- 11:00User reports UI is now correct. Verified all caches cleaned up.
We rolled out a new dashboard UI with a Webpack build. The service worker, generated by Workbox, was configured with a cache-first strategy for static assets. I deployed via Firebase Hosting and confirmed the new files were served. But within minutes, users started reporting that the old UI was still showing. I checked my own browser – I saw the new UI. Classic Heisenbug.
I opened DevTools and saw the problem: the old service worker was still active, and the new one was in 'waiting' state. Clicking 'Update' fixed it for me, but that doesn't help users. I dug into the SW code and found that the `activate` event only logged a message – it never deleted old caches. So the fetch handler continued to serve from 'myapp-v2', even though 'myapp-v3' existed. The new SW was installed but never took over.
The real issue was that we never implemented cache versioning. Workbox's default generated SW used a fixed cache name. I updated the SW to use a versioned cache name (e.g., `myapp-v${Date.now()}`) and added logic in `activate` to delete all caches not matching the current version. I also added `skipWaiting()` and `clients.claim()`. After redeploying, the new SW activated immediately and the correct UI appeared for all users.
Root cause
The `activate` event did not delete old caches, so the fetch handler continued serving from the previous cache version.
The fix
Implemented cache versioning with a timestamp-based name and added code in `activate` to delete all outdated caches. Also added `skipWaiting()` and `clients.claim()`.
The lesson
Always version your caches and explicitly clean up old ones in the `activate` event. Never assume the SW will automatically update – you must manage the lifecycle.
A service worker has three states: installing, waiting, and active. When a new SW is detected (via byte-diff or URL change), it installs but goes into 'waiting' until all pages controlled by the old SW are closed. Without `skipWaiting()`, the new SW waits indefinitely. This is by design to ensure consistency across tabs.
The browser checks for SW updates every 24 hours by default, but you can trigger an update by calling `navigator.serviceWorker.register()` with a URL that has a different content or query string. Even then, the new SW won't activate until the waiting period ends or `skipWaiting()` is called.
The simplest approach is to use a cache name that includes a build number or timestamp. In your `install` event, create a cache with that name and populate it. In `activate`, iterate over all caches and delete any whose name doesn't match the current version. Example: `caches.keys().then(keys => keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))`.
Be careful: if you delete the current cache before it's fully populated, you'll have no cache. Ensure `activate` waits until the new cache is ready. One pattern: in `install`, set a flag `self.installingCache = CACHE_NAME`, then in `activate`, check that the cache exists before deleting others.
Use DevTools > Application > Service Workers to see the state. You can 'Update' the SW, 'Unregister' it, or 'Bypass for Network' to temporarily disable it. The 'Update on Reload' checkbox forces the SW to update on the next navigation, but be aware it only works for that tab.
To programmatically inspect, add a message listener in the SW: `self.addEventListener('message', event => { if (event.data.type === 'GET_CACHES') { caches.keys().then(keys => event.source.postMessage(keys)); } })`. Then from the page, send: `navigator.serviceWorker.controller.postMessage({type: 'GET_CACHES'})` and listen for the response.
Many developers think adding a version query string to assets (like `app.js?v=2`) busts the service worker cache. It does not – the SW fetch handler can still serve the old version from cache if the cache key doesn't include the query string. You must version the cache itself, not just the asset URLs.
Another misconception: hard refresh (Cmd+Shift+R) bypasses the service worker. It does for the initial request, but subsequent fetches may still be intercepted. Use 'Empty Cache and Hard Reload' from the Network tab to truly start fresh.
Frequently asked questions
Why does my service worker show as 'waiting' even after I call skipWaiting()?
If you call `skipWaiting()` but the new SW still shows as waiting, ensure the call is inside the `install` event listener. Also, check that the SW script actually changed (byte-diff) – if the content is identical, the browser won't install a new version. Finally, verify that there are no other tabs controlling the old SW that haven't been claimed.
How do I force all users to get the latest SW immediately after deployment?
You cannot force all users instantly because browsers check for updates at most every 24 hours. However, you can shorten the delay by: 1) changing the SW URL (e.g., `/sw.js?v=BUILD_NUM`), 2) using `navigator.serviceWorker.register()` on every page load to trigger an update check, and 3) calling `skipWaiting()` and `clients.claim()` in the new SW to take control immediately.
Is it safe to delete all old caches in the activate event?
It is safe as long as you ensure the new cache is fully populated before deleting old ones. A common pattern is to wait until the `install` event completes (cache populated), then in `activate` delete all caches not matching the current version. If you delete the current cache before it's ready, the SW will have no cache and might serve stale or no content.
Can I use the same cache name across deployments and just update its contents?
Technically yes, but it's risky. If the `install` event fails or is interrupted, you might lose the cache entirely. Also, if two tabs have different SW versions, they might race on cache writes. Best practice is to use a new cache name per deployment and clean up old ones.
Why does my service worker still serve old content after I cleared the cache?
Clearing the browser cache does not delete service worker caches. You must delete them explicitly via DevTools > Application > Cache Storage, or programmatically with `caches.delete(name)`. Also, ensure the SW itself is updated – if the old SW is still active, it will re-create its caches.