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:
- Freshness vs validation — The Freshness vs Validation Models Explained page explains exactly how
max-age,ETag, and304 Not Modifiedfit together. This page builds directly on that foundation. - 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.
- Directive combinations — Headers like
no-cache,no-store, andimmutableinteract 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
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
ETagchange. - 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.js → app.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
- Open DevTools (F12) > Network tab.
- Ensure “Disable cache” is unchecked — that checkbox simulates a permanently forced reload and will mask the real browser behaviour.
- Perform a normal page load (not a hard reload).
- Click the target resource in the Network panel.
- In the Headers tab, verify Response Headers contains the directive you configured.
- 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.200with a byte size — full fetch; either stale+changed orno-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
fetchrequests before the HTTP cache layer. It can serve arbitrarily stale responses from the Cache Storage API regardless ofCache-Controlheaders. For critical deployments callskipWaiting()andclients.claim()in theactivateevent, 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-storedisables BFCache storage but also prevents all HTTP caching. A safer approach is listening for thepageshowevent withevent.persisted === trueand programmatically refreshing dynamic data without disabling caching for static assets. -
Missing validators mean no bandwidth savings from
no-cache. If the origin does not setETagorLast-Modified, ano-cachedirective causes a full200response on every revalidation — equivalent tono-storein bandwidth terms, but slower because of the extra round-trip before the body transfers. Always pairno-cachewith a stableETag. -
Vary: User-Agentcauses per-agent cache entries. If a CDN or reverse proxy passesVary: User-Agentthrough 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 useUser-Agentas 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.
Related
- The What Happens When max-age Expires page walks through the exact sequence of events a browser follows when a resource crosses from fresh to stale, including the must-revalidate vs stale-while-revalidate distinction.
- Cache Hit, Miss, and Bypass Mechanics explains the terminology and observable signals (Status 200 vs 304 vs
(disk cache)) that tell you which code path the browser took. - How to Combine Cache-Control Directives Safely covers which directive pairs are complementary and which are contradictory, with annotated header blocks for each common pattern.