What Happens When max-age Expires
When max-age reaches zero, a cached response transitions from fresh to stale as defined by RFC 9111 §4.2. At this exact moment, cache behavior diverges based on which directives accompany the freshness value, whether validators like ETag or Last-Modified are present, and which cache tier is evaluating the entry. Without explicit revalidation instructions, clients either block on a synchronous origin fetch, initiate a conditional 304 Not Modified handshake, or — under stale-while-revalidate — serve the stale response immediately while fetching asynchronously in the background.
The fresh-to-stale transition is the most consequential event in the caching lifecycle. Misunderstanding it causes increased origin load, stale asset delivery, and layout shifts from mismatched resource versions.
Prerequisite Concepts
Before working through the steps below, make sure you understand these foundational topics:
- Mastering max-age and s-maxage Directives — the two-tier TTL model, how
s-maxageoverridesmax-agefor shared caches, and howmust-revalidateandproxy-revalidateinteract with expiration. - Freshness vs Validation Models Explained — the distinction between time-based freshness (cache serves without a network round-trip) and conditional validation (
ETag/If-None-Match,Last-Modified/If-Modified-Since). - Cache Hit, Miss, and Bypass Mechanics — how a stale entry differs from a miss and why expiration does not evict the entry from storage.
The Expiration State Machine
Before stepping through the resolution procedure, the diagram below shows the decision path a cache follows the moment a request arrives for an entry whose max-age has elapsed.
Step-by-Step Resolution
Follow each step in order. Every step is independently verifiable with the commands provided.
Step 1 — Confirm the entry is genuinely stale
Before any resolution path begins, verify that the freshness window has actually elapsed. The Age response header reports how long the entry has resided in a shared cache; compare it against max-age:
curl -sI https://example.com/asset.js \
| grep -iE "^(cache-control|age|x-cache|cf-cache-status):"
Expected output when stale:
cache-control: public, max-age=60
age: 73
x-cache: HIT
Here age=73 exceeds max-age=60, so the entry is stale. A CDN still serving it is either within a stale-while-revalidate window or has a bug in its expiry logic. If age is absent, you are looking at the browser cache — switch to DevTools.
Step 2 — Check whether a stale-while-revalidate window is active
If stale-while-revalidate is set, calculate the combined freshness ceiling: max-age + stale-while-revalidate. If the Age is below this ceiling, the cache serves the stale response immediately and issues a background revalidation. The client receives a fast response; the next request after the background fetch completes receives the updated copy.
# Verify stale-while-revalidate is present and calculate remaining window
curl -sI https://example.com/asset.js | grep -i cache-control
# cache-control: public, max-age=60, stale-while-revalidate=120
# age: 90 → within the 180s total window (60 + 120), so stale-while-revalidate applies
During this window, X-Cache: HIT (stale) or CF-Cache-Status: STALE appear in CDN response headers.
Step 3 — Identify whether validators are present
Once stale-while-revalidate is exhausted or absent, the cache must revalidate before serving. Validators determine whether this is a low-cost conditional request or a full refetch:
curl -sI https://example.com/asset.js | grep -iE "^(etag|last-modified):"
If ETag or Last-Modified appears, proceed to Step 4. If neither is present, skip to Step 5 — the cache must perform a full unconditional GET.
Step 4 — Issue a conditional GET and observe the outcome
With validators available, simulate what the cache sends to origin:
# Conditional GET using ETag
curl -sI \
-H 'If-None-Match: "d41d8cd98f00b204e9800998ecf8427e"' \
https://example.com/asset.js
Two outcomes:
304 Not Modified— the stored body is still current. The cache resets its freshness clock using the newmax-agevalue from the304response headers and serves the existing body to the client. No payload transfer occurs. This is the efficient path.200 OK— the content changed. The cache replaces the stored entry with the new body and freshness directives, then serves it to the client.
Step 5 — Full unconditional refetch when no validators exist
Without ETag or Last-Modified, the cache has no basis for a conditional request. It issues a standard GET:
curl -sI https://example.com/asset.js
The origin returns 200 OK with a complete body regardless of whether the content changed. This doubles bandwidth compared to the 304 path. Every expired asset without validators incurs this cost on every expiration cycle. Configure content-hash-based ETag values on your origin server.
Step 6 — Confirm the updated freshness clock
After a successful 304 or 200 revalidation, verify that the cache has reset its freshness counter:
# Wait a few seconds, then confirm Age has reset to a low value
curl -sI https://example.com/asset.js | grep -i age
An Age: 2 or similar small value confirms the freshness window restarted. An Age that continues rising from its previous value indicates the CDN has not yet propagated the revalidated entry to all edge nodes.
Expected Output and Verification
A correctly configured origin with ETag support and stale-while-revalidate produces the following observable behavior after max-age expires:
| Request timing | CDN response | Browser action |
|---|---|---|
| Age 0 – max-age | CF-Cache-Status: HIT, Age rising |
Served from disk/memory cache without network |
| Age max-age – (max-age + stale-while-revalidate) | CF-Cache-Status: STALE, Age rising |
Receives stale body; background fetch occurs |
| After stale-while-revalidate window | CF-Cache-Status: MISS then HIT |
Receives fresh body after background fetch |
| Hard reload (Cmd+Shift+R) at any point | Bypasses all caches | 200 OK full response regardless of age |
In DevTools > Network > Size column, the transition from (memory cache) or (disk cache) to a numeric byte count with status 304 (or 200) marks the expiration event exactly. A 304 with near-zero content download time (visible in the Timing tab) confirms an efficient validation handshake. A 200 with significant content download confirms a full refetch.
For a reference on what the CF-Cache-Status and X-Cache values mean across CDN vendors, see the Cache Hit, Miss, and Bypass Mechanics reference.
Recommended Production Header
Combine a browser TTL, a CDN TTL, a stale-while-revalidate grace period, and an error fallback so that post-expiration behavior is deterministic at every layer:
Cache-Control: public, s-maxage=86400, max-age=3600, stale-while-revalidate=300, stale-if-error=86400
s-maxage=86400— CDN edge caches remain fresh for 24 hours, reducing revalidation traffic to origin.max-age=3600— browsers consider the entry fresh for 1 hour.stale-while-revalidate=300— after the respective TTL expires, caches serve stale for up to 5 minutes while fetching in the background. This eliminates blocking revalidation latency from the client’s perspective.stale-if-error=86400— if origin returns a 5xx error during revalidation, caches serve the stale response for up to 24 hours rather than propagating the error downstream.
For Nginx, apply this to separate location blocks by resource type:
location ~* \.(js|css|woff2|png|jpg|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location /api/ {
add_header Cache-Control "public, s-maxage=60, max-age=60, stale-while-revalidate=30, stale-if-error=600";
}
location / {
add_header Cache-Control "public, s-maxage=3600, max-age=300, stale-while-revalidate=60";
}
For versioned static assets with content-hashed filenames, use max-age=31536000, immutable. Because the filename changes on every deploy, max-age never expires for the same URL — expiration becomes irrelevant by design.
Important: must-revalidate and stale-while-revalidate are contradictory. must-revalidate prohibits serving stale content under any circumstances; stale-while-revalidate permits it during the grace window. Do not combine them on the same resource.
Edge Cases
-
CDN and browser expire at different times. When
s-maxageis set, CDN and browser expiry are intentionally decoupled. The CDN may serve fresh content long after the browser has expired its copy and issued a conditional revalidation. Usecurlto inspect CDN behavior separately from DevTools, which only shows the browser layer. -
Clock skew between origin and CDN. If origin and edge clocks are out of sync,
Date-relative freshness calculations produce incorrect effective ages. Always prefermax-ageoverExpires— relative duration offsets are immune to clock skew. Enforce NTP synchronization on origin servers. -
Missing validators force a full refetch on every expiration. Without
ETagorLast-Modified, every post-expiration request costs a full200 OKbody transfer. On a busy CDN edge with thousands of concurrent expirations, this concentrates a burst of origin traffic. Implement content-hashETagvalues as part of your origin configuration, not as an afterthought. -
HTTP/2 and HTTP/3 do not change expiration semantics. The
Cache-Controlheader and RFC 9111 freshness model apply identically regardless of protocol version. The difference is in multiplexing and header compression, not caching rules. A stale entry on HTTP/2 follows the same revalidation path as on HTTP/1.1. -
Shared-cache data leakage after expiry. A
publiccache that stored a response containing user-specific data (personalized HTML, session identifiers in response bodies) will attempt revalidation when stale. If the origin returns a new200 OKwith different user-specific content, the CDN may serve the wrong user’s data to a different user during the race window. Always setprivateon responses that are user-specific, and never includeSet-Cookieinpubliccached responses. See Public vs Private Cache Scope for scope rules.
Related
- Choosing between
no-cacheandno-store— no-cache vs no-store: When to Use Each explains when you want validation on every request versus prohibiting storage altogether, which directly governs whether expiration ever matters for a given resource. - Calculating the exact freshness lifetime from response headers — How to Calculate Cache Freshness Lifetime walks through the RFC 9111 formulas for
apparent_age,corrected_age, andfreshness_lifetime. - Directive stacking safety rules — How to Combine Cache-Control Directives Safely covers which directive combinations are contradictory and how to audit stacks before deployment.