Invalidating immutable hashed assets safely

This scenario sits under Cache Invalidation Patterns within Advanced Caching Strategies & CDN Architecture: you serve hashed JS/CSS with Cache-Control: immutable and want new builds to take effect without a stale HTML document pointing at hashes that no longer exist.

Immutable hashed assets are supposed to make invalidation free — a new build produces new filenames, so there is nothing to purge. The trap is the entry point. The browser still has to download an index.html (or SSR shell) that lists the current hashes. If that document is cached too aggressively, a user fetches yesterday's HTML, which references app.8f3a9c.js — a file the new deploy deleted. The result is a 404, a white screen, or a "ChunkLoadError" with no obvious cause. This is invalidation-by-omission failing because the omission was only half-applied.

HTML / hashed-asset skew An immutable index points at a deleted hash and 404s; a no-cache index revalidates and points at the live hash. Why the entry point must revalidate Broken: immutable index.html stale index.html refs app.8f3a9c.js deleted hash 404 / white screen Fixed: no-cache index.html fresh index.html refs app.7b2e1d.js live immutable hash 200 OK Immutable for content-addressed assets; revalidate the mutable pointer.

Rapid Diagnosis: HTML/Asset Hash Skew

Work this DevTools checklist when users report a blank page or chunk errors after a deploy:

  • Network tab → reload. Look for 404s on .js/.css requests. A 404 on a hashed chunk is the signature of HTML/asset skew.
  • Read the index.html response headers. curl -sI https://your-domain.com/. If you see max-age=31536000 or immutable on the document, that is the bug — the entry point is frozen.
  • Compare referenced hashes to deployed files. View source on the served HTML, grab a script src, and request it directly. A 404 confirms the HTML predates the current build.
  • Check cf-cache-status / x-cache on the HTML. A HIT with a high Age means the edge is serving a stale document.
  • Console. ChunkLoadError or Loading chunk N failed from Webpack/Vite runtimes confirms a missing dynamically-imported hash.
  • Target thresholds: 0 404s on hashed assets post-deploy, document Age resetting to near-zero after deploy, hashed assets holding immutable with max-age=31536000.

Root Cause Analysis

1. The HTML document is cached as aggressively as the assets

The most common cause. A blanket rule applied immutable or a long max-age to everything, including index.html. The browser and edge now serve an old document that references deleted hashes. Immutable is correct for the content-addressed assets and wrong for the mutable pointer to them.

2. The Service Worker precached a stale index

Even with correct HTML headers, a Service Worker running a cache-first strategy on / serves its precached index.html indefinitely. The edge is fresh; the SW shadows it. Returning visitors hit old hashes while new visitors are fine — a maddeningly partial outage.

3. Old hashed files were deleted before stale clients stopped requesting them

Immutable assets must remain retrievable for as long as any cached HTML can reference them. If the deploy wipes the previous build's files immediately, every client still holding the old index.html 404s until its document expires. Atomic-swap deploys that delete the old directory cause this.

4. Atomic deploy ordering — HTML published before assets land

If the new index.html goes live before all its referenced chunks finish uploading to the origin/edge, the first requests reference hashes that do not exist yet. A race window, but under high traffic it reliably produces 404s.

Step-by-Step Resolution

Fixes are ordered by impact: stop freezing the HTML first, then coordinate the SW, then keep old assets alive, then fix deploy ordering.

Step 1 — Cache the HTML for revalidation, not for freshness

The entry document must always check for a newer version. Apply short, revalidating headers to HTML while keeping assets immutable.

nginx
# index.html and route documents: always revalidate; never immutable
location = /index.html { add_header Cache-Control "no-cache"; }
location ~* /$       { add_header Cache-Control "no-cache"; }

# Hashed assets: immutable, one year
location ~* \.[0-9a-f]{8,}\.(js|css|woff2)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}
# trade-off: no-cache forces a conditional GET on every navigation, adding
# one round-trip. That's the price of never serving stale HTML. Don't push
# it to max-age=0 with must-revalidate unless you also need to forbid the
# brief stale-serving the edge might otherwise do under load.

The directive split here — immutable for content-addressed assets, revalidating for the mutable pointer — is the core of setting up immutable cache headers for hashed assets and the precedence rules in HTTP Cache-Control Headers Explained.

Expected outcome: the next navigation after a deploy fetches the new HTML (or a 304), eliminating references to deleted hashes — 404s on hashed assets drop to zero.

Step 2 — Make the Service Worker network-first for navigations

A cache-first SW on the document re-introduces the skew Step 1 just fixed. Serve the document network-first so the SW prefers fresh HTML, falling back to cache only offline.

javascript
// Navigation requests: network-first so the document is always current
self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => caches.match('/offline.html'))
    );
    return;
  }
  // Hashed assets stay cache-first — their URL changes when content does
  event.respondWith(
    caches.match(event.request).then((hit) => hit || fetch(event.request))
  );
  // trade-off: network-first navigations add a network hit on each load and
  // lose instant offline navigation. Keep it for the document only; cache-first
  // is correct for immutable assets and would be wrong to apply to HTML.
});

Expected outcome: returning visitors get the current document on the next online navigation; the partial outage where only repeat users break disappears. Full SW coordination is covered in Service Worker Caching Strategies.

Step 3 — Keep the previous build's assets alive across deploys

Retain old hashed files long enough for stale documents to expire. Deploy additively rather than replacing the asset directory.

bash
# Sync new assets WITHOUT deleting the previous build's hashed files
aws s3 sync ./dist/assets s3://my-bucket/assets/ \
  --cache-control "public, max-age=31536000, immutable"
# (no --delete) then prune builds older than the longest possible HTML Age
# trade-off: keeping old assets costs storage and leaves stale chunks
# fetchable. Don't keep them forever — prune anything older than your
# HTML revalidation window once no client can still reference it.

Expected outcome: clients holding an old index.html continue to load successfully until they pick up the new document; no transitional 404s.

Step 4 — Publish assets before HTML, atomically

Order the deploy so every chunk exists before the document that references it goes live. Upload assets first, verify, then flip the HTML.

bash
# 1. Upload assets, 2. verify a sample chunk, 3. only then publish HTML
aws s3 sync ./dist/assets s3://my-bucket/assets/ --cache-control "public, max-age=31536000, immutable"
curl -fsSI "https://your-domain.com/assets/$(ls dist/assets | grep -m1 '\.js$')" >/dev/null
aws s3 cp ./dist/index.html s3://my-bucket/index.html --cache-control "no-cache"
# trade-off: the verify step adds deploy time. Skip it only for low-traffic
# sites where the upload race window is unlikely to be hit by a real user.

Expected outcome: the race window where HTML references not-yet-uploaded hashes is closed; first-request 404s during deploy are eliminated.

Verification

Confirm the fix with a before/after edge check and a field signal:

bash
# HTML must revalidate; assets must stay immutable
curl -sI https://your-domain.com/ | grep -i cache-control   # expect: no-cache
asset=$(curl -s https://your-domain.com/ | grep -oE 'assets/[^"]+\.js' | head -1)
curl -sI "https://your-domain.com/$asset" | grep -i cache-control  # expect: immutable, max-age=31536000
curl -s -o /dev/null -w '%{http_code}\n' "https://your-domain.com/$asset"  # expect: 200
# trade-off: this validates the served document only. It won't catch a
# Service-Worker-shadowed stale index — test that with a returning-user
# session in an incognito-then-revisit flow, not curl.

The passing state: the document returns no-cache (or a 304 on revalidation), every hash it references returns 200, and assets carry immutable. In CI, assert these as a post-deploy gate alongside your tag-purge checks. In the field, watch RUM for a flat-line of zero ChunkLoadError / asset-404 beacons across the deploy boundary, and confirm document Age resets near zero on the edge immediately after release. Soft-purging the HTML on deploy, paired with a short stale-while-revalidate window, smooths the transition further.