Freshness vs Validation Models Explained
TL;DR: Freshness serves cached responses without a network round-trip; validation sends a lightweight conditional request and skips the payload if content is unchanged. Combining both — a max-age freshness window followed by ETag-driven conditional revalidation — eliminates unnecessary transfers at every stage of the cache lifecycle.
Cache-Control: public, max-age=300, stale-while-revalidate=60
ETag: "d41d8cd98f00b204e9800998ecf8427e"
Vary: Accept-Encoding
Mechanism & RFC Alignment
RFC 9111 §4 defines two distinct paths a cache can take when it receives a request for a stored response.
Path 1 — Freshness: The cache checks whether the stored response is still fresh — within its designated time-to-live. RFC 9111 §4.2.1 defines a response as fresh when freshness_lifetime > current_age. If fresh, the cache returns the stored response body directly. No connection to the origin is opened.
Path 2 — Validation: Once a response becomes stale (age exceeds freshness lifetime), the cache may still use the stored response if it can confirm the content has not changed. It sends a conditional GET to the origin, attaching one of two validators:
If-None-Matchcarrying the storedETagvalue — a strong or weak opaque identifier of the response body.If-Modified-Sincecarrying the storedLast-Modifiedtimestamp.
The origin evaluates the condition. If the stored copy is still current, it returns 304 Not Modified with updated headers but no body. The cache updates its stored headers (including a new max-age) and serves the original body. If content has changed, the origin returns 200 OK with a fresh body and a new set of validators.
RFC 9111 §5.2.1.4 specifies that no-cache forces validation on every request — the freshness check is bypassed entirely, but the response may still be stored. This is not the same as no-store (§5.2.1.5), which prohibits storage altogether. Including an ETag alongside no-store is contradictory: nothing is stored, so the validator can never be used.
How the cache decides which path to take
Request arrives
│
▼
Is there a stored response?
│ Yes
▼
Is the response fresh? ──── No ──→ Send conditional GET (If-None-Match / If-Modified-Since)
│ Yes │
▼ Origin: 304? ── Yes → Serve stored body, reset TTL
Serve stored response │
(no network round-trip) Origin: 200? ── Yes → Store new response, serve it
The SVG below traces this decision flow with both paths visible end-to-end.
Scope & Precedence
The freshness and validation models apply differently across cache tiers:
| Header / directive | Browser cache | Shared CDN cache | Notes |
|---|---|---|---|
max-age |
Yes | Yes (fallback if no s-maxage) |
Primary freshness TTL for private caches |
s-maxage |
Ignored | Yes (overrides max-age) |
CDN-only TTL; RFC 9111 §5.2.2.10 |
Expires |
Yes | Yes | Overridden when Cache-Control is present |
no-cache |
Forces validation | Forces validation | Both tiers validate on every request |
must-revalidate |
Post-stale only | Post-stale only | Does not force validation while fresh |
ETag / If-None-Match |
Yes | Yes | Validator for conditional GET |
Last-Modified / If-Modified-Since |
Yes | Yes | Weaker validator; timestamp-based |
Precedence rules (RFC 9111 §5.2.1):
no-store— prohibits storage; any other directive on the same response is irrelevant.no-cache— storage is allowed but validation is mandatory before serving.s-maxage— overridesmax-agefor shared caches only.max-age— overridesExpires.Expires— applies only whenCache-Controlis absent.- Heuristic freshness (RFC 9111 §4.2.2) — only when no explicit TTL is present.
Implementation Patterns
1. Immutable versioned assets (content-hashed filenames)
No validator needed — a new filename is its own cache-buster.
Cache-Control: public, max-age=31536000, immutable
The immutable extension (RFC 8246) signals to browsers that the response will not change within max-age, suppressing conditional revalidation on forced reloads. Omit ETag here: the URL itself guarantees uniqueness.
2. Frequently updated API responses with background revalidation
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400
ETag: "a3f9b71c"
Vary: Accept-Encoding
stale-while-revalidate (RFC 5861) allows the cache to serve a stale response immediately while asynchronously revalidating in the background. The ETag enables a 304 response on that background request, avoiding a full body transfer.
3. User-specific authenticated responses
Cache-Control: private, no-cache
ETag: "user-42-profile-v8"
private restricts storage to the browser only. no-cache forces validation on every request. The ETag means the round-trip payload is avoided when the profile has not changed.
4. HTML documents with short freshness and strong validation
Cache-Control: public, max-age=300, must-revalidate
ETag: "homepage-2026-06-22-v3"
Last-Modified: Sun, 22 Jun 2026 08:00:00 GMT
must-revalidate prevents serving a stale response in error conditions (origin down). Both ETag and Last-Modified are provided; caches prefer ETag when both are present.
5. Forcing validation on every request (zero-TTL)
Cache-Control: no-cache
ETag: "config-hash-c9a2"
no-cache with max-age=0 are functionally equivalent per RFC 9111. With a strong ETag, the server can still return 304 on unchanged content, so the bandwidth cost of forced validation is minimal.
Server & CDN Configuration
Nginx
# Versioned static assets — maximum freshness, no validator needed
location ~* \.(js|css|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# API endpoints — short TTL with async revalidation
location /api/ {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=300, stale-if-error=86400";
add_header Vary "Accept-Encoding";
# Nginx generates ETag from mtime + size by default; ensure consistent deployment
etag on;
}
# HTML pages — validate on every request
location / {
add_header Cache-Control "public, max-age=300, must-revalidate";
etag on;
}
Apache
# Versioned assets
Header set Cache-Control "public, max-age=31536000, immutable"
# API responses
Header set Cache-Control "public, max-age=60, stale-while-revalidate=300"
Header set Vary "Accept-Encoding"
# HTML
Header set Cache-Control "public, max-age=300, must-revalidate"
FileETag MTime Size
Cloudflare (via _headers file or Worker)
# _headers (Cloudflare Pages)
/assets/*
Cache-Control: public, max-age=31536000, immutable
/api/*
Cache-Control: public, max-age=60, stale-while-revalidate=300
Vary: Accept-Encoding
Cloudflare respects s-maxage as its CDN TTL and ignores max-age for edge caching when s-maxage is present. For split browser/CDN TTLs, use:
Cache-Control: public, max-age=60, s-maxage=3600
Interaction with Related Directives
no-cache vs must-revalidate: These are frequently confused. no-cache applies on every request — even while the response is technically fresh, the cache must validate before serving. must-revalidate only activates after a response becomes stale: it prevents the fallback behavior (RFC 9111 §5.2.2.2) that would otherwise allow a stale response to be served when the origin is unreachable.
stale-while-revalidate and the validation window: stale-while-revalidate=N gives the cache a grace period of N seconds past max-age during which it may serve the stale response synchronously and revalidate in the background. If the background revalidation receives a 304, no body is transferred. This is the most bandwidth-efficient pattern for high-traffic CDN edge nodes. See mastering max-age and s-maxage for TTL layering across tiers.
Vary and validation scope: The Vary header affects which stored response is selected for validation. Vary: Accept-Encoding means the cache maintains separate entries for gzip and identity variants — a conditional GET with If-None-Match must match both the URL and the variant. Avoid Vary: Authorization on shared caches: it creates per-user cache entries in CDN nodes, effectively defeating shared caching. See mapping Vary headers to edge routing for CDN-specific implications.
ETag generation across load-balanced origins: ETag values must be consistent across all origin nodes. Nginx’s default etag on derives the tag from file mtime and size. If deployment timing differs across nodes, requests routed to different nodes will return different ETag values for the same content, causing every conditional GET to receive a 200 instead of a 304. Generate ETags from a content hash (MD5 or SHA-256 of the body) and set them explicitly in your application layer.
Verification Workflow
Step 1: Confirm freshness directive is present
curl -sI https://example.com/api/data | grep -i 'cache-control\|expires\|age'
Expected output includes a Cache-Control header with max-age or s-maxage. The Age header (added by CDN and proxy caches) shows seconds since the response was stored — subtract from max-age to find remaining freshness.
Step 2: Capture the validator
curl -sI https://example.com/api/data | grep -i 'etag\|last-modified'
Note the ETag value. If none is present, the cache cannot perform efficient revalidation — the full body will be transferred on every stale request.
Step 3: Test conditional GET
curl -sI -H 'If-None-Match: "a3f9b71c"' https://example.com/api/data
Expect HTTP/2 304 with no body. If you receive 200, either the content changed or the origin does not support conditional requests for this endpoint.
Step 4: Verify cross-node ETag consistency
for i in 1 2 3 4 5; do
curl -sI https://example.com/api/data | grep -i etag
done
All five responses must return the same ETag value. Differing values indicate mtime-based ETags with inconsistent deployment timestamps across load-balanced nodes.
Step 5: Inspect CDN cache status
curl -sI https://example.com/api/data | grep -i 'cf-cache-status\|x-cache\|age'
CF-Cache-Status: HIT with a non-zero Age confirms freshness is active at the CDN edge. CF-Cache-Status: REVALIDATED confirms a 304 path was taken. For Fastly, check X-Cache: HIT and X-Cache-Hits.
Browser DevTools procedure
- Open Network tab. Enable “Disable cache” and hard reload — establishes a baseline with full
200responses. - Re-enable caching, reload — look for
(memory cache)or(disk cache)in the Size column (freshness model active). - Wait for
max-ageto elapse (or set a short TTL in test configuration), then reload — the Status column should show304(validation model active). - Inspect request headers on the
304request: confirmIf-None-Matchis present with the previously receivedETagvalue.
Failure Modes & Gotchas
-
no-storecombined withETag:no-store(RFC 9111 §5.2.1.5) prohibits any storage of the response. AnETagon ano-storeresponse is never stored, so no conditional GET can ever be issued — the validator is dead. If you need per-request validation without caching, useno-cacheinstead. -
ExpireswithoutCache-Controlon HTTP/1.1:Expiresis a date string; clock skew between client and server can cause a response to appear expired immediately, or to remain fresh longer than intended. RFC 9111 §5.3 makesCache-Control: max-agethe authoritative TTL — always prefer it. -
must-revalidatenot preventing fresh-time serving: A common misconception is thatmust-revalidateforces validation while the response is fresh. It does not. It only prevents serving a stale response when the origin is unreachable. For forced validation at all times, useno-cache. -
Heuristic freshness on documents: When an HTML page has no
Cache-ControlorExpiresbut has aLast-Modifiedheader, RFC 9111 §4.2.2 permits browsers to apply a 10% heuristic. A document last modified 10 days ago might be cached for 24 hours without anyCache-Controldirective present. Always set explicitCache-Controlon HTML responses to prevent unintended heuristic caching. -
stale-while-revalidateon authenticated endpoints: If a CDN node serves stale authenticated responses during the revalidation window, users may see another user’s data. SetCache-Control: privateorno-cacheon any response that is user-specific, or scopestale-while-revalidateonly to public responses. -
Vary: *disabling all caching:Vary: *tells caches that the response may vary on any request header — no stored response can ever be reused. This effectively disables caching entirely. Only useVary: *when absolutely necessary and never in combination withmax-age. -
Weak ETags and range requests: Weak ETags (prefixed
W/) indicate semantic equivalence but not byte-for-byte identity. RFC 9110 §8.8.3 prohibits using a weakETagwithIf-None-Matchin aGETrequest where byte-range responses are expected. Use strong ETags on resources that serve range requests. -
CDN
s-maxagenot set whilemax-ageis low: A shortmax-ageintended for browsers (e.g. 60 seconds) also caps CDN freshness unless a separates-maxageis provided. Under high traffic, a 60-second CDN TTL causes heavy origin load. Sets-maxageindependently to tune edge and browser TTLs separately.
FAQ
Does no-cache bypass the cache entirely?
No. no-cache requires the cache to validate the stored response with the origin before serving it, but the response is still stored and the ETag is still used. The full body is not retransferred if the origin returns 304. Only no-store bypasses storage entirely — and with it, any possibility of 304 revalidation.
Why does my browser show 304 on some reloads but 200 on others?
A normal reload (Cmd+R / Ctrl+R) uses conditional GET if the cached response is stale, producing a 304 when content has not changed. A hard reload (Cmd+Shift+R / Ctrl+Shift+R) forces the browser to ignore the cache and send the request without If-None-Match / If-Modified-Since, producing a 200. Opening DevTools and enabling “Disable cache” has the same effect as a hard reload.
When should I use Last-Modified instead of ETag?
Last-Modified is sufficient for resources where second-level timestamp granularity is adequate (static files, rarely updated assets). ETag is preferable when: the resource is generated dynamically; sub-second changes must be detected; or the content may be identical across regenerations (ETags suppress unnecessary 200 responses; timestamps do not). RFC 9110 §8.8.2 notes that ETags are a stronger mechanism. If you can provide both, do so — caches prefer ETag.
Can a 304 response carry new headers?
Yes. RFC 9111 §4.3.4 specifies that a 304 response may include updated Cache-Control, Age, ETag, Expires, and Vary headers. These replace the corresponding headers in the stored response, allowing the origin to extend or shorten the TTL without a full 200 response.
What happens if the origin is down during revalidation?
Without must-revalidate, RFC 9111 §4.2.4 permits caches to serve a stale response when the origin is unreachable (“stale-if-error” semantics are implied by default behavior, formalized by RFC 5861). With must-revalidate, the cache must return a 504 Gateway Timeout rather than serve the stale response. stale-if-error=N gives explicit control: serve stale for up to N seconds during origin errors.
Related
- Understand how the complete HTTP request lifecycle moves through browser, proxy, and CDN before reaching your origin.
- See cache hit, miss, and bypass mechanics for how caches decide whether a stored entry is eligible to serve at all.
- Learn how to combine
Cache-Controldirectives safely when stackingmax-age,s-maxage,no-cache, andstale-while-revalidateon the same response. - For browser-specific invalidation triggers (service workers, forced reloads, navigation type), see when a browser invalidates a cached resource.