must-revalidate and proxy-revalidate Explained

TL;DR: Most caching directives describe how long a response may be reused. These two describe what happens after that window closes. By default a cache retains some discretion to serve an expired copy — during an origin outage, for instance. must-revalidate removes that discretion for every cache; proxy-revalidate removes it for shared caches only. Both are inert while the response is fresh, and both mean the cache must answer 504 Gateway Timeout rather than serve stale content it cannot verify.

HTTP/2 200
Cache-Control: public, max-age=60, must-revalidate
ETag: "8f3a-71c"

For 60 seconds this behaves exactly like public, max-age=60. From second 61 onward, the stored copy is unusable until the origin confirms it.

Mechanism and RFC Alignment

RFC 9111 §4.2.4 permits a cache to serve a stale response under defined circumstances — most importantly when it is disconnected from the origin. This is a deliberate resilience feature: a proxy that can answer with a slightly old page during an outage is usually more useful than one that returns an error.

§5.2.2.2 defines must-revalidate as the directive that withdraws that permission. Once the response is stale, the cache MUST NOT reuse it without successful revalidation, and MUST return a 504 (Gateway Timeout) if revalidation cannot be completed. The specification uses MUST rather than SHOULD, which is unusual in the caching vocabulary and reflects that the directive exists for cases where serving old data is a correctness failure rather than an inconvenience.

proxy-revalidate (§5.2.2.8) carries exactly the same requirement, scoped to shared caches. Private browser caches ignore it entirely.

When the directive becomes activeA revalidation directive has no effect during the freshness lifetime; it governs only what a cache may do once the response has expired.Freshfreely reusable, directive inert0sStalereuse forbidden without a 30460s300s
max-age=60 with must-revalidate: identical for a minute, then strict

The 504 requirement is the part people find surprising. A cache holding a perfectly readable copy of a page is required to withhold it and return an error. That is the intended trade: for an account balance, an inventory count or a permissions response, an error the client can retry is safer than a plausible-looking number that is wrong.

Scope and Precedence

The two directives differ only in which tiers they bind, which makes the choice between them a question about where staleness is acceptable.

Which tiers each directive bindsmust-revalidate applies everywhere; proxy-revalidate applies only to shared caches, leaving the browser free to serve an expired copy.Browser cacheShared cacheTypical useno directivemay serve stalemay serve staleresilience preferredmust-revalidatemust notmust notcorrectness critical everywhereproxy-revalidatemay serve stalemust notedge strict, client tolerantno-cachenever reuses blindnever reuses blindstricter, applies immediately
The same rule, applied to different tiers

proxy-revalidate is the less familiar of the two, and its use case is specific. Consider a response that is expensive to generate and mildly time-sensitive: you want the edge to always hold a verified copy, because it serves thousands of users, while a single user’s browser falling back to its own slightly-stale copy during a network blip is harmless. proxy-revalidate expresses exactly that asymmetry, in the same way s-maxage expresses an asymmetric lifetime.

In precedence terms both directives are subordinate to no-store and to no-cache, and orthogonal to max-age. They never shorten a lifetime; they only constrain what happens once it ends. The full ordering is covered in header stacking and directive precedence.

One consequence of that orthogonality is worth stating: must-revalidate is meaningless without a lifetime. Cache-Control: must-revalidate alone leaves the freshness lifetime to be determined by heuristic freshness, which is almost never what the author intended. Always pair it with an explicit max-age or s-maxage.

Implementation Patterns

Correctness-critical reads. An endpoint whose stale value could cause a wrong decision — a balance, a stock level, an entitlement check — is the canonical case:

Cache-Control: private, max-age=15, must-revalidate
ETag: "acct-9471-r8823"

Fifteen seconds of free reuse absorbs the burst of requests a single page render produces, and after that every reuse is verified. The validator makes the verification cheap: a 304 with no body rather than a full refetch.

Edge-strict, browser-tolerant. A dashboard fragment that many users share, where the edge must never serve unverified data but a single user’s brief staleness is acceptable:

Cache-Control: public, max-age=30, s-maxage=120, proxy-revalidate
ETag: "frag-2f81"

Deliberately resilient content. For comparison, the opposite choice on content where staleness is preferable to an error:

Cache-Control: public, max-age=300, stale-if-error=86400

Here an origin failure produces a day of stale-but-available content. Adding must-revalidate to this line would contradict it directly.

Choosing between strictness and resilienceThe choice turns on a single question — whether a wrong value is worse than no value — and the answer differs per endpoint rather than per site.Would stale data cause a wrong decision?yesmust-revalidatenoIs the risk only at the shared tier?yesproxy-revalidatenoIs an outage worse than slightly old data?yesstale-if-errornoPlain max-age is sufficient
One question decides which side of the trade you want

Server and CDN Configuration

# Correctness-critical API: short lifetime, no stale serving anywhere
location /api/accounts/ {
    add_header Cache-Control "private, max-age=15, must-revalidate" always;
    etag on;
}

# Shared fragment: strict at the edge, tolerant in the browser
location /fragments/ {
    add_header Cache-Control "public, max-age=30, s-maxage=120, proxy-revalidate" always;
}

    Header always set Cache-Control "private, max-age=15, must-revalidate"
    FileETag None

At the CDN, the important detail is whether the vendor honours the directive at all. Fastly respects must-revalidate and will return an error rather than serve stale when the backend is unreachable, unless a stale-if-error window or an explicit VCL grace period overrides it:

sub vcl_backend_response {
    if (beresp.http.Cache-Control ~ "must-revalidate") {
        # No grace: the directive says stale serving is not permitted
        set beresp.grace = 0s;
    }
}

Cloudflare’s “Always Online” feature is precisely the behaviour must-revalidate forbids — serving a stored copy when the origin is unreachable. If you rely on the directive for correctness, confirm that feature is disabled for the affected routes, because a dashboard toggle that contradicts a response header is a difficult incident to diagnose after the fact.

The interaction that causes the most trouble is with the stale-serving directives described in serving stale content with stale-while-revalidate. must-revalidate says an expired response must not be reused; stale-while-revalidate says it may be, for a defined window. These are direct contradictions, and RFC 9111 does not specify a resolution. Some implementations honour the stricter directive, others the more permissive one. Sending both is a bug rather than a nuance.

The relationship with no-cache is one of strictness in time. no-cache applies from the moment of storage: nothing is ever reused without revalidation. must-revalidate applies only after expiry. A response marked no-cache, must-revalidate is simply no-cache with redundant text, since the stale window it governs can never be entered without a revalidation having already occurred. The distinction between the two is developed further in no-cache vs max-age=0.

With immutable the pairing is contradictory in spirit if not in letter: immutable asserts the representation will not change during its lifetime, so a directive governing what happens when it expires is at best unnecessary. See immutable and versioned asset caching for why fingerprinted assets need neither.

With Authorization, must-revalidate has a side effect worth knowing: like public and s-maxage, its presence is one of the conditions that permits a shared cache to store a response to an authenticated request. Adding it to an authenticated route therefore does more than tighten staleness — it may also make the response storable at the edge for the first time.

Verification Workflow

Step 1 — confirm the directive is present end to end. CDNs and proxies rewrite Cache-Control more often than people expect:

# At the origin, bypassing the CDN
curl -sI --resolve example.com:443:203.0.113.10 https://example.com/api/accounts/9471 \
  | grep -i cache-control

# Through the CDN
curl -sI https://example.com/api/accounts/9471 | grep -i cache-control

Step 2 — verify free reuse during the lifetime. Within max-age, requests should hit without contacting the origin:

curl -sI https://example.com/api/accounts/9471 | grep -iE '^age|cf-cache-status'
sleep 5
curl -sI https://example.com/api/accounts/9471 | grep -iE '^age|cf-cache-status'

A climbing Age with a HIT confirms the directive is not being misread as no-cache.

Step 3 — verify revalidation after expiry. Wait past max-age and confirm the next request produces a conditional exchange rather than a stale hit. An Age value that resets to zero, or a REVALIDATED status where the vendor reports one, is the expected outcome.

Step 4 — verify the outage behaviour. This is the only test that actually exercises the directive. In a staging environment, stop the origin and request an expired object:

# With the origin unreachable and the stored entry expired
curl -sI https://staging.example.com/api/accounts/9471 | head -1
# Expected: HTTP/2 504

A 200 here means something in the path — a grace window, a vendor always-online feature, a proxy default — is overriding the directive.

The Availability Trade, Stated Plainly

Every use of these directives converts a caching decision into an availability decision, and it is worth making that conversion explicit before adopting them broadly.

A route without must-revalidate degrades gracefully. When the origin is unreachable, caches that hold an expired copy may answer with it. Users see content that is minutes or hours out of date, the site remains navigable, and the incident is invisible to most visitors. The cost is that some of those users are acting on information that is no longer true.

A route with must-revalidate degrades sharply. The same outage produces 504 responses for every request whose stored copy has expired, and the proportion of affected users climbs as the outage continues — first the requests whose entries were nearly expired, then progressively more. The benefit is that nobody is ever shown a stale value, because the alternative to correct data is no data rather than wrong data.

Neither is universally right, and the useful discipline is to decide per endpoint rather than per site. Ask what a user would do with a value that is five minutes old. For a documentation page or a marketing headline the answer is “nothing different”, and resilience wins. For a remaining-seats count, an account balance, a permission grant or a price at checkout, the answer is “possibly something irreversible”, and strictness wins.

There is a middle position that is under-used. A short max-age with must-revalidate and a strong validator gives correctness with most of the performance: the lifetime absorbs the burst of requests a single page render produces, revalidation after that costs a round trip but no payload, and the origin sees a request rate bounded by the lifetime rather than by user count. The version of this pattern that goes wrong is a long lifetime with must-revalidate, which delivers neither property — a long window of unverified reuse followed by hard failure the moment it closes.

Finally, treat the directive as a dependency to be monitored. A route that is strict about staleness has an availability ceiling set by the origin’s own availability, and that ceiling should appear in the same dashboard as the origin’s error rate. The failure mode of forgetting this is a site whose measured uptime is excellent everywhere except the handful of endpoints that matter most.

Failure Modes and Gotchas

  1. Expecting an effect while the response is fresh. The directive changes nothing until expiry. Teams add it hoping to force revalidation and see no change, then conclude it is unsupported.

  2. Combining it with stale-while-revalidate. Contradictory, unspecified, and resolved differently by different caches. Choose one.

  3. A vendor feature overriding it silently. Always-online and grace-period features exist precisely to serve stale content during an outage. Either disable them for strict routes or accept that the directive is advisory in your stack.

  4. Using it without a lifetime. With no max-age the freshness window is decided heuristically, so the point at which strictness begins is unpredictable.

  5. Applying it site-wide. Every strict route becomes an availability dependency on the origin. Marketing pages and documentation gain nothing from a 504 during an outage, and lose a great deal.

  6. Confusing proxy-revalidate with private. proxy-revalidate does not stop shared caches storing the response; it only stops them serving it stale. If the content must not be stored at the edge at all, that is private.

  7. Forgetting the validator. Without an ETag or Last-Modified, every post-expiry request becomes a full refetch rather than a 304. The directive turns from a cheap correctness guarantee into a bandwidth cost.

Frequently Asked Questions

Does must-revalidate do anything while a response is fresh?

No. It is inert for the whole freshness lifetime. public, max-age=300, must-revalidate and public, max-age=300 behave identically for 300 seconds and diverge only afterwards.

What is the difference between must-revalidate and proxy-revalidate?

Scope. must-revalidate binds every cache; proxy-revalidate binds shared caches only and leaves browsers free to serve an expired copy. Use the second when strictness matters at the edge but a single user’s brief staleness does not.

Can must-revalidate and stale-while-revalidate be used together?

They contradict each other and should not be combined. One forbids serving an expired response, the other explicitly permits it for a window, and implementations resolve the conflict inconsistently.

Is must-revalidate the same as no-cache?

No. no-cache requires revalidation before every reuse, starting immediately. must-revalidate permits unrestricted reuse for the full lifetime and constrains only what happens after expiry.

What does a cache return when it cannot revalidate?

504 Gateway Timeout. The directive forbids answering with the expired copy, so an error is the only conformant response — which is the whole point when a wrong value is worse than no value.


Guides in This Topic

Each guide below works through one concrete task or question from this topic, end to end.

Back to Cache-Control Directives & Header Combinations