Service Worker Serving Stale HTML With New Hashes

The report is always the same shape: a subset of users see a blank page or a route that never renders, the console shows ChunkLoadError: Loading chunk 12 failed, and the asset it names looks perfectly well-formed — /assets/route-settings.a3f8c1d2.js, an eight-character hex hash, exactly what your build emits. It resolves for nobody because the build that produced it was replaced three deploys ago and the files were deleted. The document asking for it came out of Cache Storage.

This is not a CDN problem, and purging the edge will not help. The document is being served by a service worker from a cache that no header can expire.

What the Error Actually Says

Bundlers surface the same underlying failure with different text, and knowing the mapping saves the first ten minutes of an incident.

Signal Emitted by What it means
ChunkLoadError: Loading chunk N failed Webpack 5 runtime A <script> inserted for a dynamic import returned a non-2xx or failed to parse
Loading CSS chunk N failed mini-css-extract-plugin The stylesheet for a lazily loaded route 404’d
Failed to fetch dynamically imported module Vite 5 / native ESM The browser’s own module loader got a non-2xx for an import()
Unexpected token '<' in a .js request Any bundler behind an SPA fallback The origin answered a missing asset with index.html instead of a 404
Blank route, no console error React error boundary swallowing the rejection Same failure, caught and rendered as nothing

The fourth row is worth calling out because it hides the real cause. An origin configured with try_files $uri /index.html returns 200 with HTML for a missing chunk, so the loader fails on parse rather than on status, and every dashboard that alerts on 404 rate stays green. Scope the SPA fallback to navigation requests only, and let asset paths 404 honestly.

The confirming observation in DevTools takes under a minute. Open Network, find the failing request, and check the Size column: a value of (ServiceWorker) on the document request while the chunk request shows a real 404 is the exact fingerprint of this failure. Then open Application → Cache Storage, find the document entry, and compare the hashes inside it against the hashes in a fresh curl of the same URL. If they differ, the worker is holding a document from an older build.

How a cached document breaks a new deploy A cached build-41 document triggers a dynamic import for a build-41 chunk hash; the origin now holds only builds 42 and 43, so the request 404s and the loader raises a chunk load error. How a cached document breaks a new deploy Cached document build 41 HTML served by the worker import() Dynamic import route chunk requested hash from build 41 GET Origin lookup only builds 42 and 43 old hash deleted 404 ChunkLoadError rejection, not a retry blank route or screen Uncaught (in promise) ChunkLoadError: Loading chunk 12 failed. (missing: /assets/route-settings.a3f8c1d2.js) The hash is well-formed. The build that produced it no longer exists.
The document and the assets it references are from different generations — the worker is the only thing keeping the older one alive.

Why no CDN action helps

It is worth being explicit about why the usual reflexes fail here, because an incident channel will suggest all of them. Purging the edge removes the CDN’s copy of the document, but the client never asks the CDN — the worker answers from Cache Storage before a network request is created. Shortening the document TTL changes what the CDN and the HTTP cache do, and Cache Storage reads neither. Re-uploading the missing hash does work, but only for the exact chunks you happen to identify, and it silently reintroduces the file whose deletion you are still debugging.

The one edge-side action that does help is unrelated to the document: confirming that the asset paths still resolve. If the referenced hashes are present at the origin, a stale document is harmless — the page renders an older version of the app and recovers on the next navigation. The failure only becomes visible when staleness on the client meets deletion at the origin, which is why the fixes below attack both sides.

Cause, Signal, Fix

Root cause How to confirm Fix
Document precached or navigateFallback set The precache manifest contains index.html Remove HTML from the glob; drop navigateFallback
Document on a cache-first route Network tab shows (ServiceWorker) and no origin request Move documents to NetworkFirst
Old builds deleted at deploy curl the referenced hash and get 404 Retain the previous 3–5 builds at origin
activate deleted the asset cache mid-session Cache Storage lost entries while a tab was open Do not call skipWaiting() unconditionally
SPA fallback returns HTML for assets Failing chunk request returns 200 text/html Scope the fallback to navigations

The first three account for nearly every occurrence. They are also independent, which is why the fix is layered rather than singular: make the document fresh, keep the old assets alive long enough for the documents already in the wild, and give the client a way to recover when both of those fail.

Fix 1: Documents on a Network-First Route

A document is mutable at a stable URL, so a cache-first strategy is wrong for it by construction — the same reason the Cache-Control reference puts HTML on no-cache while assets get immutable. In a worker the equivalent is a network-first route where the cached copy exists only as an offline fallback.

// Documents: network wins, cache is a fallback, with a bounded timeout so a
// dead network does not hang the navigation.
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

const documentStrategy = new NetworkFirst({
  cacheName: 'documents',
  networkTimeoutSeconds: 4,
  plugins: [new CacheableResponsePlugin({ statuses: [200] })],
});

registerRoute(new NavigationRoute(documentStrategy, {
  // Never let the worker answer these from cache.
  denylist: [/^\/api\//, /^\/auth\//, /^\/admin\//],
}));

networkTimeoutSeconds: 4 is the part people leave out. Without it, network-first on a captive-portal connection — where requests hang rather than fail — blocks the navigation until the browser’s own timeout, which is far longer than any user will wait. Four seconds is long enough for a slow mobile round trip and short enough that falling back to a one-build-old document feels like a recovery rather than a hang.

If you maintain the worker by hand, the same shape applies: fetch first, cache.put on a 200 response, and cache.match only in the catch. The important negative is that nothing writes documents into the cache during install.

Fix 2: Retain Previous Builds at the Origin

Even with a perfect document strategy there is a window: a user who loaded the page thirty seconds before the deploy is holding build-41 HTML in memory, and their next route transition asks for a build-41 chunk. That is not a caching bug — it is the normal condition of any long-lived tab, and the only defence is not deleting assets the moment a new build lands.

Origin retention window for hashed assets Four horizontal bars show builds 40 through 43. Each build's assets stay resolvable at the origin for three subsequent deploys, so documents cached from an older build still find their chunks. Origin retention window for hashed assets build 43 deploy build 40 retained, then swept build 41 still resolvable at origin build 42 covers stale documents build 43 current build older now Sweep build N-4 only; anything newer may still be referenced by a live document.
Retention converts an unbounded failure into a bounded one: any document younger than the window still resolves.

Because filenames are content-addressed, overlapping generations cost only disk — there is no ambiguity about which bytes a URL refers to, which is the property cache key architecture exists to guarantee. Implement it by never using --delete on the asset prefix and sweeping separately.

#!/usr/bin/env bash
# sweep-assets.sh — keep hashed assets from the last N builds, drop the rest.
set -euo pipefail

BUCKET="s3://example-com-origin"
KEEP_BUILDS=4
MANIFEST_PREFIX="build-manifests"

# 1. Publish assets additively — never with --delete.
aws s3 sync dist/assets "${BUCKET}/assets" \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "*.map"

# 2. Record the exact asset list this build depends on.
BUILD_ID="$(git rev-parse --short=8 HEAD)"
find dist/assets -type f -printf '/assets/%P\n' | sort > "manifest-${BUILD_ID}.txt"
aws s3 cp "manifest-${BUILD_ID}.txt" "${BUCKET}/${MANIFEST_PREFIX}/"

# 3. Union of the last N manifests = the set that must survive.
aws s3 ls "${BUCKET}/${MANIFEST_PREFIX}/" \
  | sort -k1,2 | tail -n "${KEEP_BUILDS}" | awk '{print $4}' \
  > recent-manifests.txt

: > keep.txt
while read -r name; do
  aws s3 cp "${BUCKET}/${MANIFEST_PREFIX}/${name}" - >> keep.txt
done < recent-manifests.txt
sort -u keep.txt -o keep.txt

# 4. Delete anything under /assets that no recent build references.
aws s3 ls "${BUCKET}/assets/" --recursive \
  | awk '{print "/"$4}' | sort > live.txt

comm -23 live.txt keep.txt | while read -r path; do
  echo "sweeping ${path}"
  aws s3 rm "${BUCKET}${path#/}"
done

Four builds is a sensible default for a team deploying a few times a day; teams deploying continuously should size the window in wall-clock time instead — retain everything referenced in the last 48 hours, which is roughly the lifetime of a browser tab someone forgot about. The manifest-union approach also keeps shared vendor chunks alive automatically, since an unchanged chunk appears in every recent manifest. Wire the sweep as a scheduled job rather than a deploy step, as covered in the CI/CD asset pipeline guide.

Fix 3: A Client-Side Chunk-Error Guard

Retention shrinks the window; it does not close it. A tab open for a week outlives any retention policy, so the last layer is a guard that turns a fatal chunk failure into a single reload.

Chunk-error reload guard A failed chunk import checks a session marker. If the page has already reloaded once, an error UI is shown; otherwise the marker is set and the document is reloaded to pick up fresh hashes. Chunk-error reload guard Chunk import fails Already reloaded once? yes no Show error UI offer a retry action never loop the reload Reload the document network-first HTML fresh hashes arrive
One reload recovers the session; the session marker is what stops it becoming an infinite loop.
// chunk-guard.js — import once, as early as possible in the entry bundle.
const MARKER = 'chunk-reload-attempted';

function isChunkFailure(reason) {
  if (!reason) return false;
  const message = String(reason.message || reason);
  return (
    reason.name === 'ChunkLoadError' ||
    /Loading (CSS )?chunk [^ ]+ failed/.test(message) ||
    /Failed to fetch dynamically imported module/.test(message) ||
    /error loading dynamically imported module/i.test(message)
  );
}

function recover(reason) {
  if (sessionStorage.getItem(MARKER)) {
    // Second failure in this session: the reload did not help.
    // Surface a real error instead of looping.
    document.dispatchEvent(new CustomEvent('app:stale-build', { detail: reason }));
    return;
  }
  sessionStorage.setItem(MARKER, String(Date.now()));

  // Drop worker-held documents so the reload cannot be answered from cache.
  const cleanup = 'caches' in window
    ? caches.keys().then((names) =>
        Promise.all(names.filter((n) => n.startsWith('documents')).map((n) => caches.delete(n)))
      )
    : Promise.resolve();

  cleanup.finally(() => window.location.reload());
}

window.addEventListener('unhandledrejection', (event) => {
  if (isChunkFailure(event.reason)) {
    event.preventDefault();
    recover(event.reason);
  }
});

window.addEventListener('error', (event) => {
  if (isChunkFailure(event.error)) recover(event.error);
});

// Clear the marker once the app has rendered successfully.
window.addEventListener('load', () => {
  requestAnimationFrame(() => sessionStorage.removeItem(MARKER));
});

Two details make this safe. The marker lives in sessionStorage, so it is per-tab and disappears when the tab closes — a user who hits the same failure tomorrow still gets their one free reload. And the marker is cleared on a successful load, so a genuine network blip does not permanently consume the allowance.

If you use a React router with lazy routes, wrap the same logic in an error boundary and call recover from componentDidCatch; the promise rejection may be caught by React before it ever reaches unhandledrejection.

Verification

# Take the hashes the live document references, and prove they all resolve.
curl -s https://example.com/ \
  | grep -oE '/assets/[A-Za-z0-9._-]+\.[0-9a-f]{8,16}\.(js|css)' \
  | sort -u \
  | while read -r path; do
      code="$(curl -s -o /dev/null -w '%{http_code}' "https://example.com${path}")"
      type="$(curl -s -o /dev/null -w '%{content_type}' "https://example.com${path}")"
      printf '%s %s %s\n' "$code" "$type" "$path"
    done | grep -v '^200 '
# Any output at all is a broken reference. An entry showing "200 text/html"
# means the SPA fallback is masking a 404 — fix that before anything else.

Run the same loop against a previous deploy’s document to confirm retention is working: fetch the HTML from your last release artefact rather than from the origin, and every hash it names should still return 200. That is the direct test of Fix 2, and it is the one worth running on a schedule rather than only during incidents.

When to Reconsider

When the site has no dynamic imports at all. A server-rendered site whose pages each load one stylesheet and one script cannot produce a chunk-load error — the document and its assets arrive together, and a stale document simply renders older content. Precaching documents is still inadvisable, but the reload guard is dead code.

When the stale document is intentional. If you serve HTML with stale-while-revalidate at the edge on purpose, a briefly outdated document is the design, not the bug — and it is safe precisely because retention keeps the referenced assets alive. That trade-off is worked through in stale-while-revalidate for HTML entry points. What is never safe is the combination of a stale document with an aggressive origin sweep.

When the failure is not the document at all. If the referenced hash resolves fine from curl but fails in the browser, you are looking at a broken or pinned worker rather than a stale one, and the recovery is the incident procedure in forcing a service worker update after a bad deploy.