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:

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.

Cache decision flow after max-age expires A flowchart showing the path from a stale cache entry through four possible outcomes: serving stale during stale-while-revalidate, blocking revalidation yielding 304 Not Modified, blocking revalidation yielding 200 OK, and bypassing to a full origin fetch when must-revalidate is set. Request for stale entry stale-while-revalidate window active? Yes Serve stale immediately + background fetch No must-revalidate present? Yes Block; strict revalidation (no stale fallback) No ETag or Last-Modified available? Yes Conditional GET → 304 or 200 No Unconditional GET → 200 full response

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 new max-age value from the 304 response 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.

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-maxage is 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. Use curl to 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 prefer max-age over Expires — relative duration offsets are immune to clock skew. Enforce NTP synchronization on origin servers.

  • Missing validators force a full refetch on every expiration. Without ETag or Last-Modified, every post-expiration request costs a full 200 OK body transfer. On a busy CDN edge with thousands of concurrent expirations, this concentrates a burst of origin traffic. Implement content-hash ETag values as part of your origin configuration, not as an afterthought.

  • HTTP/2 and HTTP/3 do not change expiration semantics. The Cache-Control header 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 public cache 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 new 200 OK with different user-specific content, the CDN may serve the wrong user’s data to a different user during the race window. Always set private on responses that are user-specific, and never include Set-Cookie in public cached responses. See Public vs Private Cache Scope for scope rules.


Related

  • Choosing between no-cache and no-storeno-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, and freshness_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.

Back to Mastering max-age and s-maxage Directives