Web delivery · beginner

Users receive an old browser bundle after release: trace cache headers and manifest

Users keep receiving an older browser bundle after a new release because an HTTP cache, service worker, or CDN edge is still serving a previously hashed asset set. This guide explains how to trace cache headers and the HTML asset manifest to localize the staleness boundary between origin, CDN, browser memory cache, and disk cache. Use it when post-deploy evidence shows stale JavaScript, CSS, or HTML even though fresh assets exist on origin.

The symptoms

  • Users report UI behavior, copy, or assets that match the previous release after a deployment is reported complete in CI/CD.
  • Browser DevTools Network panel shows older hashed filenames (e.g., app.abc123.js) for the current release's expected filenames (e.g., app.def456.js).
  • The HTML document fetched from origin contains the new asset hashes, but the bytes returned by the network for those hashed URLs are the old bundle.
  • A hard reload (bypassing cache) shows the new bundle, while a normal reload or returning visitor still receives the old bundle.
  • CDN cache hit ratio is high for asset paths that should have been purged or invalidated by the deployment hook.

Likely causes

  • CDN or reverse proxy is serving from cache because Cache-Control or Surrogate-Key headers allow long TTLs and the purge/ban step did not cover the HTML manifest.
  • The deployment wrote new hashed assets but the HTML index that references them was cached separately and not invalidated atomically with the asset upload.
  • Service worker precache or AppCache retains the previous bundle and intercepts requests before the network sees the new HTML.
  • Browser memory or disk cache returns the previous HTML on back/forward navigation because the document response carries no cache validators (ETag, Last-Modified) or carries immutable caching that outlives the deployment window.
  • Origin server or build pipeline emits stale-by-design headers (max-age far in the future, public) on the HTML response while asset responses are correctly versioned, so the manifest is reread only on hard reload.

First ten minutes

  1. 01Open DevTools, disable cache, and reload to confirm whether the new bundle renders; this separates client cache from upstream cache as the staleness boundary.
  2. 02In the Network panel, sort by the document and inspect its response headers (Cache-Control, Age, ETag, Last-Modified) to see whether the HTML itself is cached and for how long.
  3. 03For each asset path the HTML references, inspect Cache-Control, Age, and x-cache/Hit/Miss style headers to determine which layer served each byte.
  4. 04Diff the HTML response between a normal reload and a hard reload; identical bytes confirm an upstream cache rather than a client cache for the document.
  5. 05If a service worker is registered, check Application/Service Workers for an active worker and the current precache manifest version, since it can serve stale assets independent of HTTP caching.
  6. 06Check the deployment log for the purge/ban step and its scope (URL paths vs. surrogate keys vs. tags) to see whether the HTML manifest was covered.
  7. 07Capture the exact Cache-Control directives seen for the HTML and for at least one hashed asset before changing anything, so any policy change is reversible.

Evidence to collect

  • Cache-Control, Age, ETag, Last-Modified, Vary, and CDN-specific HIT/MISS or POP/region headers for the HTML document response.
  • Cache-Control, Age, and HIT/MISS headers for each hashed JavaScript and CSS asset referenced by the HTML.
  • The full HTML response body so the asset manifest (script src and link href hashes) can be compared against the bytes actually returned for those URLs.
  • Service worker registration, current scope, and precache manifest version (if applicable) from DevTools Application tab.
  • Deployment record showing upload timestamps, asset paths, and any purge/ban API call parameters (paths, surrogate keys, tags, status).
  • Origin access log entries for the HTML and one hashed asset around the deployment window, including x-forwarded-for and response status.

Where to look

  • Browser-side: DevTools Network response headers and Application/Service Workers pane; document the boundary between disk cache, memory cache, and service worker.
  • CDN edge: POP response headers (HIT/MISS, age, cache status), purge queue, surrogate-key/tag coverage for the HTML and asset paths.
  • Origin web server: response headers emitted by the build artifact server or reverse proxy, especially for the HTML index and its cache headers.
  • Build/release pipeline: the manifest file written at build time, the upload step, and the purge/invalidation step, including ordering relative to the asset upload.
  • Service worker scope and precache list: the SW source/version in the repository and what version string it advertises to clients.

Diagnostic steps

  1. 01Establish ground truth by fetching the HTML from origin (bypassing any CDN edge) and confirming it references the new asset hashes for this release.
  2. 02For each asset URL the HTML references, fetch it with cache-busting query parameters to see whether origin returns the new bytes; if origin has new bytes but edge returns old bytes, the staleness is at the CDN layer.
  3. 03Re-fetch the HTML through the public endpoint and compare it to the origin HTML; a difference indicates the HTML itself is cached at an upstream layer.
  4. 04Compare Cache-Control on the HTML against the asset responses; long max-age on the HTML with short or immutable max-age on assets is the most common mismatch behind this failure mode.
  5. 05If a service worker is in scope, verify it is not the layer serving stale bytes by unregistering it in DevTools and reloading; returning correct bytes after unregister places the boundary at the SW precache.
  6. 06Cross-check the deployment purge step's scope: a purge that targets asset paths but not the HTML manifest will leave the manifest cached and pointing at assets that the purge then invalidates, producing a window of broken references.
  7. 07Check whether the HTML response uses fingerprinting (e.g., meta refresh, build-id cookie) and whether the build pipeline rotates that fingerprint atomically with asset upload.
  8. 08Use the Performance API timing entries (responseStart, transferSize, encodedBodySize) for the HTML and one asset to distinguish a 304 revalidation, a 200 from disk cache, and a 200 from network; this localizes the boundary without changing anything.

Common mistakes

  • Assuming "users see the old site" means the build is wrong, when in fact the build is correct and a cache layer is serving the previous HTML manifest.
  • Invalidating only asset paths and not the HTML document, which leaves the HTML pointing at freshly purged assets that now return cache MISS with the old bytes still present at another POP.
  • Setting long max-age on the HTML while relying on fingerprinting, but then re-deploying without rotating the HTML's own URL or query string, so caches never revalidate.
  • Trusting a successful purge API response without checking that the purge covered the exact surrogate keys/tags the HTML response was tagged with at the time it was cached.
  • Ignoring service workers as a separate caching layer; a registered worker can serve stale assets even when the HTML and HTTP caches are correct.
  • Reading only Cache-Control and missing Age or HIT/MISS, which leads to misattributing a CDN-cached response to the origin or browser cache.

Safe fixes

  • If the HTML is cached upstream and references new asset hashes, purge the exact HTML URL and its surrogate keys (not just the asset paths) and re-verify by re-fetching through the public endpoint and comparing bytes.
  • If only the HTML is the problem, add or strengthen validators (ETag, Last-Modified) on the HTML response and reduce its max-age so clients and edges revalidate on the next request, then re-test.
  • If a service worker is the boundary, publish a new SW version that triggers update on next navigation and includes the current asset list; coordinate this with the HTML purge so the two arrive together.
  • If the build pipeline rotates asset hashes but not the HTML URL, introduce a build-id fingerprint on the HTML URL (or query string) so caches treat it as a new resource after each release.
  • If purge scoping is unreliable, tag both the HTML and its assets with a shared surrogate key per release and invalidate that key after upload completes, then verify with a HIT/MISS check on a known edge.
  • If origin headers are the issue, change the HTML's Cache-Control to a short max-age with must-revalidate (or no-cache with validators) while leaving hashed assets immutable, then re-test on a non-cache-busted reload.

Prove the fix

  1. 01After the change, a normal reload (cache enabled) of the HTML through the public endpoint returns the new manifest bytes, verified by comparing its <script src>/<link href> hashes to the build output for this release.
  2. 02Each asset URL the HTML references returns a 200 with the new bundle bytes on a normal reload, and the response header shows MISS or revalidated (HIT with a recent Age) rather than a long-aged HIT carrying old bytes.
  3. 03In DevTools, a fresh browser profile (no prior visits) loads the new bundle on first navigation, demonstrating the fix is not dependent on local cache state.
  4. 04If a service worker was involved, Application/Service Workers shows the new worker version as activated and controlling, and its precache list matches the current release's hashed assets.
  5. 05Repeat the verification from a second edge region if the CDN is multi-POP, confirming the purge/ban propagated and Age resets to a small value for the HTML response.

Prevention and next steps

  • Define a single release identifier (build id or version tag) that is included in both the HTML URL/fingerprint and a shared surrogate key on the HTML and asset responses.
  • Make the deployment pipeline atomic from the cache's perspective: upload assets, write the new HTML, then invalidate the release's surrogate key, in that order, with the invalidation step's scope validated by an automated post-deploy probe.
  • Use short or no-cache with validators on the HTML response and long immutable caching only on content-addressed hashed assets, so caches must revalidate the manifest on each release.
  • Keep the service worker update strategy aligned with releases: bump a version constant on every release and have the SW skipWaiting/clients.claim so updates take effect on next navigation.
  • Add a post-deploy smoke test that fetches the HTML from at least two edge regions and asserts that referenced asset hashes match the current build output and that the HTML was not served from a long-aged cache entry.

Safe commands and checks

Use the DevTools Network panel to inspect response headers; no command line is required for the initial cache-header trace.
In a browser console, log Performance API timing for the HTML response to distinguish cache layers: performance.getEntriesByType('resource').filter(e => e.initiatorType === 'navigation') and inspect transferSize and encodedBodySize.
In a browser console, list currently registered service workers and their scopes: navigator.serviceWorker.getRegistrations().then(rs => rs.map(r => ({scope: r.scope, active: !!r.active}))).
From a workstation, fetch the HTML through the public endpoint with a cache-busting query parameter (e.g., ?cb=<token>) using a generic HTTP client to compare against a fetch without the parameter; do not target loopback addresses.
From a workstation, fetch one hashed asset the HTML references, with and without a cache-busting query parameter, using a generic HTTP client, and compare response bytes and Cache-Control/Age headers; substitute the real asset URL at runtime.