Skip to main content

Why a service worker serves stale chunks after a deploy

A browser window loading a cached page shell served by a service worker
Jun 18, 20262 min readPWA, Next.js, React, Performance, Vercel

A fix that looks broken

I ship a focus-restoration fix for the command palette: open it, hit Escape, and focus returns to whatever element opened it. Works on localhost. I deploy, reload production, and focus drops to the body instead.

So I rewrite it. Deploy. Still broken. I try a different approach built on requestAnimationFrame, deploy, reload, and it's still broken. Four rebuilds, four deploys, the same dead behavior. The code is correct in the editor and correct on localhost, so production must be running something else.

Proving the browser runs old code

The quickest way to settle "is my code even loaded" is to make the new code announce itself, so I add a marker the old build can't possibly have:

// in the palette's open handler
console.debug('palette open: focus-restore build 5');

Deploy, hard reload, open the palette, and the console stays silent. Build 5's JavaScript is sitting right there on the server, but the browser is happily executing an earlier build. Nothing about my fix is wrong; the browser just never receives it.

How stale HTML pins stale JavaScript

Next.js fingerprints every chunk: /_next/static/chunks/4f3a…b1.js. The filename changes whenever the contents do, which is exactly what makes those files safe to cache forever. The HTML document is the thing that maps the app onto a specific set of those hashed URLs, so a new build means new chunk hashes and new <script> references in the document.

The site is a PWA, and the service worker was serving HTML with a stale-while-revalidate strategy: hand back the cached document immediately, then refresh the cache in the background. That's the right call for content that can stand to be one visit out of date, but it's the wrong call for an app shell. Every reload handed the browser the previous build's HTML, which pointed at the previous build's chunks. The worker never cached my JavaScript at all; it cached the document that decides which JavaScript to load. My fix shipped on every single deploy, and the worker routed around it every single time.

Network-first for the document

The fix is to stop treating the HTML as cacheable content. Navigations go network-first: fetch fresh HTML when online, and fall back to the cache only when the network actually fails.

// HTML pages - network-first.
// Must NOT be stale-while-revalidate: the cached HTML pins content-hashed
// chunk URLs from the build it was captured on, so after a deploy SWR would
// serve the old app shell referencing old/now-missing chunks.
if (
  request.mode === 'navigate' ||
  request.headers.get('accept')?.includes('text/html')
) {
  return {
    cache: DYNAMIC_CACHE,
    strategy: 'network-first',
    limit: DYNAMIC_CACHE_LIMIT,
  };
}

The strategy itself is tiny: try the network, cache a copy on success, and only reach for the cache when the fetch throws.

case 'network-first':
  event.respondWith(
    fetch(event.request)
      .then(response => {
        if (response && response.status === 200) {
          const responseClone = response.clone();
          caches.open(cache).then(c => c.put(event.request, responseClone));
        }
        return response;
      })
      .catch(() => caches.match(event.request))
  );
  break;

One detail closes the loop: a deployed service worker won't drop its old caches on its own. So I bumped CACHE_VERSION from v3 to v4, which makes the activate handler delete every cache that isn't on the current version list, including the stale HTML that kicked all of this off.

The result

  • Online navigations always get fresh HTML with current chunk references.
  • Offline still works: the cache is the fallback now, not the default.
  • The focus fix, untouched since build 2, started working the moment the worker stopped serving last week's document.

The takeaway

A service worker can make a perfectly correct fix look broken, and it shows up in neither your diff nor your tests. The trap is caching the one file you can't afford to serve stale: the HTML doesn't hold your app, it holds the addresses of your app. Before you debug the same fix a fifth time, prove the browser is actually running the build you think it is.