When Does a Browser Invalidate a Cached Resource?

Frontend and DevOps teams frequently discover that browsers continue serving stale JavaScript or CSS bundles after a deployment. The mirror problem also occurs: misconfigured headers force revalidation on every single request, eliminating the performance benefit of caching and increasing origin load unnecessarily. In both cases the root cause is the same — confusing freshness expiry with invalidation. Browsers follow RFC 9111 strictly and do not arbitrarily purge cached entries in response to server-side changes.

Prerequisite Concepts

Before working through the steps below, make sure you are comfortable with these three ideas:

  1. Freshness vs validation — The Freshness vs Validation Models Explained page explains exactly how max-age, ETag, and 304 Not Modified fit together. This page builds directly on that foundation.
  2. Cache hierarchy — Browsers sit at the bottom of a layered stack. The Understanding HTTP Cache Hierarchy page shows how CDN edge nodes, reverse proxies, and browser caches each maintain independent stores with independent TTLs.
  3. Directive combinations — Headers like no-cache, no-store, and immutable interact in non-obvious ways. Mastering max-age and s-maxage Directives covers the precedence rules.

The Four Conditions That Actually Invalidate a Browser Cache Entry

Browser cache invalidation decision flow A flowchart showing the four conditions under which a browser discards or revalidates a cached response: no-store directive, no-cache directive, freshness expiry (Age >= max-age), and hard reload. If none apply, the cached copy is served directly. Browser request no-store present? YES Never stored. Full fetch required. NO no-cache or Age ≥ max-age? YES Conditional GET (304 or new 200) NO Serve from cache (fresh) Hard reload forces Conditional GET regardless

RFC 9111 defines four and only four triggers that cause a browser to stop serving a cached copy directly:

1. Freshness expired (Age >= max-age)

The browser computes a resource’s freshness lifetime from Cache-Control: max-age=N (or, if absent, the legacy Expires header). It tracks elapsed time via the Age response header and its own clock. Once Age >= max-age the resource is stale — the browser will not serve it without first revalidating with the origin. A matching ETag / If-None-Match handshake can produce a 304 Not Modified response, refreshing the TTL without transferring the body.

2. no-cache directive

no-cache does not prevent storage — it prevents serving the stored copy without revalidation. On every request the browser sends a conditional GET (If-None-Match or If-Modified-Since). If the origin confirms the content is unchanged, the browser reuses the cached body. This is efficient for resources that change infrequently but must never be served stale.

3. no-store directive

no-store is the only directive that truly prevents caching. The browser must not write the response to any storage and must perform a complete fetch (with full payload transfer) on every request. Use this only for genuinely sensitive data such as session tokens or personalised health records — applying it to static assets is a common performance mistake covered in no-cache vs no-store: When to Use Each.

4. Hard reload (Ctrl+Shift+R / Cmd+Shift+R)

A hard reload causes the browser to inject Cache-Control: no-cache into outgoing requests for the page and its sub-resources. The locally cached copies are effectively bypassed for that reload; the browser validates everything before serving. A normal reload (F5) still serves fresh cached resources directly.

What does NOT invalidate a browser cache entry

  • Deploying a new file at the same URL while the old entry is still fresh.
  • CDN cache purges — these clear edge nodes only; browsers holding a fresh copy are unaffected.
  • Server-side restarts or deployments without a URL or ETag change.
  • The browser reaching storage limits — eviction under pressure is not deterministic and should never be relied upon.

Step-by-Step Resolution

Step 1 — Fetch the current headers and compute freshness

curl -sI https://example.com/static/app.js \
  | grep -Ei "cache-control|age|etag|last-modified|expires"

Expected output for a properly configured immutable asset:

Cache-Control: public, max-age=31536000, immutable
Age: 84320
ETag: "a3f8c2d1"

Compute remaining freshness: 31536000 - 84320 = 31451680 seconds. This resource is fresh for roughly another 364 days. Any browser that received this response is serving it from cache without contacting the origin.

Step 2 — Identify the controlling directive

Parse the Cache-Control value in priority order:

Priority Directive Browser behaviour
1st no-store Never cached; full fetch every time
2nd no-cache Cached but always revalidated
3rd max-age=0, must-revalidate Stale immediately; revalidate before use
4th max-age=N (N > 0) Fresh for N seconds; served directly
5th Expires (if no max-age) Fresh until the Expires timestamp
Fallback None of the above Heuristic freshness (≈10% of Last-Modified age)

If Cache-Control is absent entirely, the browser applies heuristic caching. This is unpredictable — always send an explicit directive for every resource.

Step 3 — Confirm the origin can handle conditional requests

Send a conditional GET manually to verify the origin returns 304 correctly:

# First, get the ETag
ETAG=$(curl -sI https://example.com/static/app.js | grep -i etag | awk '{print $2}' | tr -d '\r')

# Then send a conditional request
curl -sI -H "If-None-Match: $ETAG" https://example.com/static/app.js \
  | grep -E "HTTP/|ETag|Cache-Control"

A correct origin returns HTTP/2 304 with no body. If it returns 200 OK every time regardless of If-None-Match, the origin is not generating stable ETags — the revalidation model cannot work efficiently.

Step 4 — Apply the correct fix for your scenario

Scenario A: Stale JS/CSS bundle after deploy (same URL, changed content)

Switch to content-hashed filenames. Modern bundlers (Webpack, Vite, Rollup) do this automatically:

# Nginx — long TTL only for content-hashed files
location ~* \.(js|css|png|woff2)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

The new filename (app.a3f8c2d1.jsapp.9b71d224.js) is a new URL — the browser has no cached entry for it and fetches fresh content. The HTML document that references these assets should use a short or zero TTL so browsers always pick up the updated filename references:

location /index.html {
    add_header Cache-Control "public, max-age=0, must-revalidate";
}

Scenario B: Mutable resource that must always reflect current content

location /api/user/profile {
    add_header Cache-Control "private, no-cache";
}

private prevents CDN storage; no-cache forces the browser to validate before every use. The origin must supply stable ETag values so 304 responses skip payload transfer.

Scenario C: Truly sensitive data that must never persist

location /api/session {
    add_header Cache-Control "no-store";
}

Step 5 — Reproduce and verify in Chrome DevTools

  1. Open DevTools (F12) > Network tab.
  2. Ensure “Disable cache” is unchecked — that checkbox simulates a permanently forced reload and will mask the real browser behaviour.
  3. Perform a normal page load (not a hard reload).
  4. Click the target resource in the Network panel.
  5. In the Headers tab, verify Response Headers contains the directive you configured.
  6. Reload a second time. In the Size column:
    • (disk cache) — served directly; resource is fresh.
    • (memory cache) — served from tab’s in-memory cache; also fresh.
    • 304 — stale, but origin confirmed unchanged; body reused.
    • 200 with a byte size — full fetch; either stale+changed or no-store.

Expected Output / Verification

After applying content-hashed filenames with max-age=31536000, immutable:

# First load (cache miss)
HTTP/2 200
cache-control: public, max-age=31536000, immutable
etag: "9b71d224"
content-length: 48320

# Subsequent loads within freshness window
Status: 200 (disk cache)
# No network request issued at all

After applying max-age=0, must-revalidate to the HTML entry point:

# Every page load
GET /index.html HTTP/2
If-None-Match: "d4e5f6a7"

HTTP/2 304
cache-control: public, max-age=0, must-revalidate
etag: "d4e5f6a7"
# Body not transferred — browser reuses cached copy

A correctly configured CDN adds its own layer. The How CDN Cache Keys Are Generated page explains how Age is forwarded from edge to browser and why a browser may see a resource as nearly stale even immediately after a CDN cache hit.


Edge Cases

  • Service Worker interference. A registered Service Worker intercepts fetch requests before the HTTP cache layer. It can serve arbitrarily stale responses from the Cache Storage API regardless of Cache-Control headers. For critical deployments call skipWaiting() and clients.claim() in the activate event, and implement an explicit version-check to prompt users to reload. HTTP cache invalidation rules are bypassed entirely when a Service Worker handles the request.

  • Safari back/forward cache (BFCache). Safari can restore pages from an in-memory snapshot on back/forward navigation, skipping all HTTP cache logic. Setting Cache-Control: no-store disables BFCache storage but also prevents all HTTP caching. A safer approach is listening for the pageshow event with event.persisted === true and programmatically refreshing dynamic data without disabling caching for static assets.

  • Missing validators mean no bandwidth savings from no-cache. If the origin does not set ETag or Last-Modified, a no-cache directive causes a full 200 response on every revalidation — equivalent to no-store in bandwidth terms, but slower because of the extra round-trip before the body transfers. Always pair no-cache with a stable ETag.

  • Vary: User-Agent causes per-agent cache entries. If a CDN or reverse proxy passes Vary: User-Agent through to the browser, every distinct User-Agent string creates a separate cache bucket. Browsers will constantly miss cache on minor browser version increments. Route mobile/desktop variants at the edge before the cache lookup — never use User-Agent as a cache-busting mechanism.


FAQ

Can I force a specific user’s browser to drop a cached file right now?

No. RFC 9111 provides no server-push invalidation mechanism for browser caches. Your options are: wait for the TTL to expire; instruct the user to hard-reload; or, for future deployments, use content-hashed filenames so the next deployment produces a URL the browser has not yet cached.

Does setting ETag on its own affect browser caching TTL?

No. ETag is a validator — it makes revalidation efficient (enabling 304 responses) but does not set or change a resource’s freshness lifetime. TTL is controlled exclusively by max-age (or s-maxage for shared caches), Expires, or heuristic freshness.

What happens when max-age and Expires conflict?

Per RFC 9111 section 5.3, Cache-Control: max-age always wins over Expires when both are present. Expires is only consulted as a fallback when Cache-Control is absent entirely.

Is immutable safe to deploy without content-hashed filenames?

No. The immutable directive tells browsers to skip conditional revalidation during the declared freshness window — even on a hard reload. If the content at that URL changes without a filename change, users will be served stale content with no revalidation escape hatch until max-age expires. Only pair immutable with URLs that are guaranteed to be unique per content version.



Back to Freshness vs Validation Models Explained