LEARN · DEBUGGING GUIDE

Service Worker Serving Stale Cache: Diagnose and Fix

When a service worker holds onto old cache and ignores new network responses, users see stale data. This guide gives you concrete commands and checks to find the root cause and fix it.

IntermediateFrontend8 min read

What this usually means

The service worker's fetch event handler is not properly implementing a network-first or stale-while-revalidate strategy, or the cache-busting mechanism is missing. More often, the issue is that the service worker itself is not being updated: the old SW script remains in control because the new SW fails to activate (e.g., due to an activate event listener that does not call `event.waitUntil(clients.claim())` or the new SW is stuck in 'waiting' state). Another common cause is aggressive caching of HTML pages in the cache API without a versioning scheme, so the SW always returns the cached HTML even when the network has a newer version.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Open Chrome DevTools > Application > Service Workers. Check the status: is the new SW in 'waiting' or 'activated'?
  • 2In the same panel, click 'Update' on the service worker and watch if the new version activates immediately.
  • 3Check the 'Network' tab: reload the page and look for responses marked '(from ServiceWorker)'. Compare the response headers (especially `Content-Length` and `Last-Modified`) with a direct network request.
  • 4Add `console.log()` inside the `fetch` event handler to see which strategy path is taken (e.g., `event.respondWith(caches.match(event.request)...` vs `fetch(event.request)`).
  • 5Use the 'Update on reload' checkbox in DevTools Service Workers panel to force skip the waiting phase for testing.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchChrome DevTools > Application > Service Workers (status, clients, update loop)
  • searchChrome DevTools > Application > Cache Storage (list all caches and their contents)
  • searchService worker script file (sw.js) – especially the `install`, `activate`, and `fetch` event handlers
  • searchBrowser's console for any errors during service worker installation or activation
  • searchServer response headers (Cache-Control, ETag) for the page and assets – they influence the SW's caching behavior
  • searchWeb app manifest or script that registers the SW – check for `updateViaCache` setting
  • searchBuild pipeline output – verify that the SW file name or content changes on each deployment (e.g., via hash in file name)
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningNew service worker never activates because `self.skipWaiting()` is not called or the user does not close all tabs
  • warningFetch event handler always falls back to cache without checking network first (pure cache-first strategy for navigation requests)
  • warningActivate event does not delete old caches, so the SW continues to serve from an outdated cache store
  • warningCache key does not include version or timestamp, so the same URL always hits the same cache entry
  • warningService worker script is cached by the browser itself (via HTTP Cache-Control), preventing update detection
  • warningRegistration code uses `updateViaCache: 'none'` incorrectly, or the SW file is served with a long `max-age`
  • warningThe `fetch` event handler uses `event.respondWith()` with a promise that resolves to the cached response without falling back to network
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildAdd `self.skipWaiting()` in the install event and `clients.claim()` in the activate event to ensure the new SW takes control immediately.
  • buildImplement a 'stale-while-revalidate' strategy: return cached response immediately but trigger a network fetch to update the cache for next time.
  • buildFor navigation requests (HTML), use 'network-first' strategy: try network, on failure fall back to cache.
  • buildVersion your cache names (e.g., `my-app-v2`) and delete old caches in the activate event to prevent stale data.
  • buildAdd a cache-busting query parameter (e.g., `?v=Date.now()`) to the request URL when caching to ensure uniqueness.
  • buildIn the registration code, set `updateViaCache: 'none'` to force the browser to always check for a new SW script.
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAfter deploying the fix, open a new tab, load the page, and confirm the new service worker is active (DevTools > Application > Service Workers).
  • verifiedPerform a hard refresh (Cmd+Shift+R) and verify that the response comes from the network (check Network tab).
  • verifiedUpdate the content on the server (e.g., change a text string) and reload; the new content should appear without manual cache clearing.
  • verifiedClose all tabs of the origin, reopen, and confirm the SW still serves fresh content after a few seconds.
  • verifiedUse the 'Update on reload' checkbox to simulate a fresh SW install and verify the new cache is populated with correct data.
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningUsing `caches.match(event.request)` in fetch handler without a fallback – this will always return cached if exists, ignoring network.
  • warningForgetting to call `event.waitUntil()` inside install or activate events – the SW lifecycle may abort prematurely.
  • warningRelying on `location.reload(true)` – hard reload does not bypass the service worker; only 'Shift+Reload' or DevTools checkbox does.
  • warningCaching the service worker script itself with a long `Cache-Control` max-age – the browser will not check for updates.
  • warningUsing `cache.addAll()` in install with a list that includes URLs that are not versioned – they will never update until the SW changes.
  • warningNot testing with 'Update on reload' disabled – the fix may only work in dev mode with forced updates.
( 07 )War story

Production Shows Old Dashboard Data After Deployment

Frontend EngineerReact PWA with Workbox, served via Nginx, deployed on Kubernetes

Timeline

  1. 09:00Deploy new version of dashboard app with updated API endpoints.
  2. 09:15Users report seeing old data on the dashboard despite multiple refreshes.
  3. 09:20Developer checks own browser: sees old data. Hard refresh (Cmd+Shift+R) shows new data temporarily, but after a few seconds reverts to old.
  4. 09:25Open DevTools > Application > Service Workers: old SW still active, new SW in 'waiting' state.
  5. 09:30Inspect fetch event: it uses a cache-first strategy for all requests, including the main HTML document.
  6. 09:35Check cache storage: 'my-app-cache-v1' contains old HTML and JS bundles. No 'v2' cache exists.
  7. 09:40New SW's activate event does not delete old caches; also missing `skipWaiting()` call.
  8. 09:45Add `self.skipWaiting()` in install and delete old caches in activate. Deploy fix.
  9. 09:50Users refresh and see fresh data. SW now updated and serving from new cache.

I pushed a new build of our dashboard app at 9 AM. The build included updated API paths and a new service worker script. By 9:15, Jira tickets were flooding in: 'Dashboard shows old data after refresh.' I reproduced immediately: my browser showed stale metrics from last week. A hard refresh worked for a second, then the old data came back. I knew this was a service worker issue.

Opening DevTools, I saw the problem: the old service worker was still 'activated', and the new one was stuck in 'waiting'. The fetch handler was a simple cache-first: it served the cached HTML without ever hitting the network. The cache 'my-app-cache-v1' still held the old assets. The new service worker's activate event didn't clean up old caches, and it never called `skipWaiting()`, so the browser waited for all clients to close before activating.

The fix was straightforward: add `self.skipWaiting()` in the install event to force immediate activation, and in the activate event, delete all caches not matching the current version. I also changed the fetch strategy for navigation requests to network-first. After redeploying, I tested with a fresh tab and the dashboard showed live data instantly. The lesson: always handle the service worker lifecycle properly and version your caches.

Root cause

New service worker stuck in 'waiting' because `skipWaiting()` was not called; old cache was never deleted; fetch handler used cache-first for HTML.

The fix

Added `self.skipWaiting()` in install, removed old caches in activate, switched to network-first for navigation requests.

The lesson

Always implement skipWaiting and cache cleanup in activate. Use network-first for HTML to avoid stale content.

( 08 )Service Worker Lifecycle: Why Updates Don't Take Effect

When a new service worker script is detected (by byte-difference), the browser installs it but keeps it in 'waiting' state until all pages under the SW's scope are closed. This prevents two versions from running simultaneously and causing race conditions. The `install` event is fired, and if it completes successfully, the new SW moves to 'waiting'. It only becomes 'active' after the old SW is no longer controlling any clients.

To force the new SW to activate immediately, you must call `self.skipWaiting()` inside the install event. Then, in the activate event, call `clients.claim()` to take control of uncontrolled clients. Without these, users must close all tabs or wait for the next navigation to a new tab to see the new version. This is the #1 reason for stale cache after deployment.

( 09 )Cache Strategy: Choosing the Right Fetch Handler

The fetch event determines whether the SW serves from cache or network. Cache-first is dangerous for HTML because it always returns the cached version, ignoring network updates. Network-first is safer: it tries the network first, and only falls back to cache if the network fails. Stale-while-revalidate serves cached content immediately but updates the cache asynchronously, which can be good for assets but risky for dynamic data.

For a PWA, the typical pattern is: network-first for navigation requests (HTML), stale-while-revalidate for static assets (JS, CSS, images), and cache-only for pre-cached resources (like the app shell). Always ensure your cache names include a version string (e.g., `my-app-v2`) so that you can delete old caches during activation.

( 10 )Caching the Service Worker Script Itself

The browser must fetch the SW script to detect updates. If the server serves the SW script with a `Cache-Control: max-age=86400` header, the browser may not re-fetch it for a day, delaying updates. To force frequent checks, serve the SW script with `Cache-Control: no-cache` or set `max-age` to 0. Alternatively, in the registration call, you can set `updateViaCache: 'none'` to bypass the HTTP cache entirely.

However, `updateViaCache` only affects the SW script, not the resources it caches. Also, note that some browsers (Safari) ignore this setting. The safest approach is to configure your server (Nginx, CDN) to never cache the SW file, e.g., by adding a `Cache-Control: no-store` header specifically for the SW path.

( 11 )Debugging with DevTools: Practical Steps

Chrome DevTools provides the most comprehensive SW debugging tools. Under Application > Service Workers, you can see the current state of all SWs, their status (waiting/active), and the clients they control. The 'Update' button forces the browser to fetch the SW script again. 'Update on reload' checkbox makes the browser treat the next reload as if the SW was updated, which is useful for testing.

Under Application > Cache Storage, you can browse all caches and their entries. Right-click a cache to delete it. To verify which cache is being served, use the Network tab and look for the '(from ServiceWorker)' line. Click on the request to see the 'Initiator' details, which may show which cache was hit.

( 12 )Automated Testing for SW Updates

To prevent stale cache in production, include automated tests in your CI pipeline. For example, use Puppeteer to register a SW and verify that after a simulated deployment, the new SW takes control and serves fresh content. You can also assert that the cache names are versioned and old caches are deleted.

A simple test script: launch a browser, navigate to the page, wait for SW activation, then modify a resource (e.g., change a text file on a test server), simulate a SW update by calling `registration.update()`, and verify that the page reflects the change. If the test fails, the SW logic is broken.

Frequently asked questions

Why does hard refresh (Cmd+Shift+R) still show stale content?

Hard refresh only bypasses the browser's HTTP cache for the page, but it does not bypass the service worker. The SW's fetch event still intercepts the request and may return a cached response. To force the SW to go to the network, you need to use the 'Update on reload' checkbox in DevTools or disable the SW temporarily.

How do I force a service worker to update immediately for all users?

In your SW code, call `self.skipWaiting()` in the install event, and `clients.claim()` in the activate event. Also, ensure the SW script is not cached by the server (serve with `Cache-Control: no-cache`). Then, on the client side, you can call `registration.update()` periodically to check for updates. However, users still need to reload the page to get the new SW active if they have multiple tabs open.

What is the difference between 'waiting' and 'installed' states?

After the install event completes, the SW enters 'installed' state (which is also called 'waiting' in the UI). It stays there until it becomes the active SW. 'Activated' means the SW is controlling clients and can handle fetch events. A SW in 'waiting' state will not intercept requests until it is activated.

Can I use cache-first for API data to improve performance?

You can, but you must have a strategy to invalidate the cache when the data changes. For example, include a version in the API URL (e.g., `/api/data?v=2`) or use a cache key that includes a timestamp. However, for dynamic data, network-first or stale-while-revalidate is safer to avoid serving stale data to users.

Why does updating the service worker script not trigger an update?

The browser checks for SW script updates by comparing the byte content of the existing script with the fetched one. If your build pipeline produces identical bytes (e.g., due to a build cache), the browser will not see a difference. Also, if the SW script is served with a long `max-age` cache header, the browser may not re-fetch it at all. Use `updateViaCache: 'none'` and ensure the script changes on each deployment (e.g., by including a hash in the file name).