Caching has two separate decisions: may a stored response be reused without contacting the server, and how can the client check whether that response still represents the resource? Calling them “two clocks” is a mnemonic. Freshness really is an age calculation; validation is a comparison, not another timer. A response can be fresh by policy while already differing from a newly deployed origin, without the cache violating HTTP.

The formula age less than lifetime makes age 59 fresh and ages 60 and 61 stale. Stale entries send If-None-Match; a match returns 304, updates metadata and reuses the stored body, while a changed GET returns 200 with a new body. A fresh cached A can still be used after origin B is deployed.
Simplified age example: matching variant, no overriding request directive. no-cache still requires validation; must-revalidate forbids unvalidated stale reuse. Validator matching does not prove byte identity.

Freshness: how long to reuse without asking

Cache-Control: max-age=60 gives a response a freshness lifetime of 60 seconds. A cache compares that lifetime with the response’s current age, accounting for Date, Age, transit time, and time stored. At exactly 60 seconds it is stale, not fresh for one more second. Shared caches prefer s-maxage when present; absent explicit freshness, some responses can receive heuristic freshness.

A fresh response is usually reusable, but matching the cache key and Vary fields and obeying request/response directives still matters. no-cache requires successful validation before reuse even when the response could otherwise be fresh. no-store prohibits storage; it is not a synonym for revalidate. public permits shared-cache storage in cases otherwise restricted; it does not specify a duration.

For mutable HTML, a short explicit lifetime plus validation is often useful. It intentionally permits updates to take up to that lifetime to become visible through a compliant cache. A whole-tree deployment does not invalidate existing browser or CDN entries. Content-addressed assets can instead use a long lifetime when the URL changes with the bytes.

Validation: ask whether the stored representation still matches

An ETag is an opaque validator, not a checksum the client should decode. Last-Modified is a timestamp validator. Neither header specifies an explicit freshness lifetime, and a server need not supply both. When heuristic freshness is permitted, however, a cache can use Last-Modified to estimate a lifetime. For example, these illustrative headers express a lifetime and a validator separately:

Cache-Control: max-age=60, must-revalidate
ETag: "release-a"

After expiry, If-None-Match: "release-a" lets a GET receive 304 Not Modified if the selected representation still matches, or a new 200 with a body if it changed. The cache updates applicable metadata from the 304 and reuses its stored body. A 304 is not useful without a corresponding stored representation. If-None-Match uses weak comparison, so successful validation does not necessarily prove byte-for-byte identity; If-Range and combining partial responses have stronger requirements.

The server can compute an ETag from bytes or other reliably changing metadata. A strong tag must distinguish representations whose data differs, including content codings where applicable. Sending the same strong ETag for different gzip and identity bytes is not fixed by adding a short lifetime. Vary: Accept-Encoding also tells a cache which request header affected selection.

A small exercise that separates the decisions

This Python model deliberately excludes heuristic freshness, network delay, and variant selection. age stands for the already calculated current age; the conditional request uses a server validator that has not changed. The model compares two fixed tag strings; it does not implement HTTP entity-tag parsing, tag lists, or the full weak-comparison rules.

lifetime = 60
stored_etag = '"release-a"'
server_etag = '"release-a"'
for age in (59, 60, 61):
    fresh = age < lifetime
    action = "reuse without validation" if fresh else (
        "validate: 304, reuse body" if stored_etag == server_etag
        else "validate: 200, replace body"
    )
    print(age, action)

The first line reuses without validation; the other two validate and reuse after a 304. Set server_etag to "release-b": the stale cases become 200, while the 59-second case still reuses the stored response. That is the permitted visibility delay the freshness policy buys in exchange for lower latency and origin load.

The contract worth asserting

Test matching and changed validators, freshness at the boundary, and the selected encoding. Also decide what should happen during an origin outage. HTTP can permit stale reuse under defined conditions or extensions; must-revalidate prohibits reusing the response once stale without successful validation, even when disconnected. This increases correctness pressure at the cost of availability. A validator alone makes no such promise.

A useful release record therefore states the cache policy, selected representation, observed conditional status, and tolerated update delay. Requiring two validator headers and one 304 cannot prove that every cache has current bytes, prevent all bandwidth waste, or replace testing the actual intermediary configuration.

Sources