Internet Protocols · HTTP
Cache Freshness, Age, and the Directive Precedence Rules
How a cache computes whether a stored response is fresh, how Age accumulates across hops, and which directive wins when several apply.
A cache’s decision to reuse a stored response reduces to one comparison: is the response’s age less than its freshness lifetime? Everything else in the caching specification exists to define those two quantities precisely enough that two caches in a chain, written by different people, agree on the answer. The definitions are more delicate than the arithmetic suggests.
Freshness lifetime from explicit directives
Cache-Control: max-age=600 on a response sets the freshness lifetime to 600 seconds from the moment the response was generated. The value is a delta-seconds integer, and RFC 9111 requires caches to treat a value that overflows their arithmetic as the largest representable value, not as zero. That rule exists because max-age=999999999999 is a common idiom for “effectively forever”, and a cache that wraps the number to a small value would expire it immediately.
Expires is the older mechanism and still appears in traffic. Its value is an HTTP-date, and the freshness lifetime is that date minus the response’s Date. Origins are required to send Date; when it is missing, RFC 9111 tells the cache to use the time it received the message in place of Date, so the response remains cacheable but the lifetime rests entirely on the cache’s clock. A response with an Expires date in the past is stale on arrival. The field’s weakness is that it depends on two clocks agreeing: the origin’s clock setting Date and Expires, and the cache’s clock reading them. max-age avoids the comparison by being relative.
When both appear, max-age wins. The specification says a cache must use max-age and ignore Expires if both are present, which lets a server send Expires for HTTP/1.0 intermediaries while controlling modern caches through Cache-Control.
Two directives cut across that ordering. s-maxage states a separate lifetime for shared caches and overrides max-age for them, which is how a site tells a CDN to hold a response longer or shorter than a browser should. immutable (from RFC 8246) does not change the lifetime at all; it tells a client not to revalidate even on an explicit reload, and it is appropriate only for content whose URL changes whenever the bytes change.
The heuristic path
A response with no explicit freshness information may still be cached and reused. RFC 9111 permits a cache to assign a heuristic lifetime, and suggests a fraction of the time since Last-Modified, with 10 percent as a typical value. The specification does not mandate the fraction; it imposes only worst-case constraints on the result, and it forbids heuristics outright when an explicit expiration time is present, or when the status code is not defined as heuristically cacheable and the response carries no explicit mark of cacheability such as public.
Heuristic freshness is the mechanism that makes responses without Cache-Control cacheable at all, and it is also the mechanism that produces the most surprising stale content. A resource last modified two years ago gets a heuristic lifetime of roughly 73 days at the 10 percent rule. A server that changes the resource tomorrow but forgets to update the cache headers leaves every intermediary holding the old copy.
Status codes are heuristically cacheable or not by definition. 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, and 501 are defined as heuristically cacheable in RFC 9110. That list is worth knowing because a 404 stored and reused is a deliberate design: the resource’s absence is a property of the URI, and re-asking on every request is waste. A 500 is not on the list, and neither is 302 without explicit freshness.
Age: how old is this response really
The age of a response is not the time since the cache received it. It is an estimate of how long the response has existed since the origin generated it, including time spent in upstream caches and in transit. RFC 9111 defines the calculation in a specific order, and the order matters.
The cache records the time it sent the request (request_time) and the time it received the response (response_time). It reads the response’s Date field. It computes the apparent age as response_time - Date. It computes the response delay as response_time - request_time. A conservative corrected age is the larger of apparent age and the sum of the Age field already on the response plus the response delay; a cache that trusts every upstream Age field may use that sum alone. The resident time in this cache is added on top for any later use.
Three details are load-bearing. The maximum with zero guards against a Date in the future, which would otherwise produce a negative age and an eternally fresh entry. The Age field already present is added because an upstream cache may have held the response; a response that arrives with Age: 3600 is already an hour old no matter what the local clock says. The response delay is included because the request was in flight while the origin was generating the response, and that time is part of the response’s age.
A cache must generate an Age field whenever it reuses a stored response without validating it, replacing any Age the response arrived with, and a response it merely relays without storing should not gain one, since the presence of the field is what tells a recipient the response did not come straight from the origin. The field value is a non-negative integer number of seconds; a cache that receives a list-valued or otherwise invalid Age uses the first member or ignores the field.
def current_age(now, request_time, response_time, date_value, age_field):
apparent = max(0, response_time - date_value)
delay = response_time - request_time
corrected = max(apparent, age_field + delay)
resident = now - response_time
return corrected + resident
def is_fresh(now, stored):
lifetime = stored.max_age if stored.max_age is not None else stored.heuristic
return current_age(now, stored.request_time, stored.response_time,
stored.date, stored.age) < lifetime
Directive precedence and the request side
no-store forbids storing the request or response at all. no-cache is different and commonly misread: it permits storage but requires successful validation before reuse. A response with no-cache is not uncacheable; it is cacheable but always stale. private permits storage in a private cache such as a browser and forbids storage in a shared one, which is what makes it the correct directive for authenticated responses. public marks a response cacheable even when it would otherwise be excluded, which is useful for responses to requests carrying Authorization, where caching is forbidden by default unless a directive explicitly permits it.
must-revalidate forbids serving a stale response once it has expired, even when the cache is disconnected from the origin. Without it, a cache is permitted to serve stale content when it cannot reach the origin, which is the behaviour behind stale-if-error in RFC 5861. proxy-revalidate is the shared-cache equivalent.
Request directives mostly constrain what a cache may return rather than what it may store, and the specification phrases them as preferences: a cache may honour max-age, max-stale, min-fresh, or no-cache from a request, but is not required to. no-store is the exception, and it binds: a cache must not store any part of the request or of any response to it. Cache-Control: no-cache in a request states that the client does not want a stored response reused without successful validation on the origin, and max-age=0 makes a weaker statement of the same intent. only-if-cached asks the cache to return a stored response or 504 without contacting the origin at all, which is useful for offline operation. max-stale and min-fresh adjust the freshness test in opposite directions: max-stale=60 accepts a response up to a minute past expiry, min-fresh=30 expresses a preference for at least thirty seconds of remaining freshness.
Request and response directives are not symmetric. A response with max-age=3600 served to a request with max-age=60 should not be reused once it is older than 60 seconds, because the client has stated it does not want a response that old; but a cache that ignores the request’s preference is not violating the specification. A response directive, by contrast, binds: a response with no-store cannot be stored regardless of what the request asks.
Invalidation is not expiry
Expiry is time-based and passive; invalidation is event-based and active. When an unsafe method (PUT, POST, PATCH, DELETE) produces a non-error response, every cache the request passed through must invalidate its stored response for the target URI. Invalidating the URIs named in the response’s Location or Content-Location is optional, and is disallowed outright when those URIs name a different origin. No other entry is required to go, so a write that changes a collection listing leaves the collection’s cached copy stale until its own lifetime runs out.
The practical consequence is that max-age on a listing page and max-age on its members are independent promises, and a site with long lifetimes on both will show inconsistent state after a write. The remedy is either short lifetimes on the derived views or explicit invalidation through a mechanism outside HTTP, such as a purge request to the CDN. HTTP itself provides no way for an origin to tell a cache “this other URI is now stale”, which is a real gap and the reason CDN APIs exist.
A cache that stores a response with a validator can revalidate cheaply, and the conditional request path is what keeps a short max-age affordable. A lifetime of zero with a strong validator costs one round trip per use but transfers no body on a hit; a lifetime of an hour with no validator costs nothing on a hit but serves stale content for up to an hour after any change. The middle setting, a few minutes plus a validator, is the usual compromise, and it is the one most origins should be able to justify.
One last asymmetry: no-cache responses still consume cache storage, and a cache full of entries that always require validation has no hit-rate benefit but still pays the storage and lookup cost. A response that will never be reused without revalidation is better marked no-store when the bandwidth a 304 would save is not worth the storage it costs.