proxy-revalidate vs must-revalidate: Which to Send

Problem Statement

Two directives express the same rule — an expired response must not be reused without revalidation — and differ only in which caches they bind. Choosing between them is a question about where staleness is actually harmful, and getting it wrong either leaves a shared cache free to serve old data or makes every browser dependent on origin availability for no benefit.

Prerequisite Concepts

Step-by-Step Resolution

Step 1 — Ask the question separately for each tier

The decision is not “is staleness acceptable” but “is staleness acceptable here”. A shared cache serving a stale response affects every user in a region simultaneously; a browser serving one affects one person who can usually resolve it by reloading.

The same staleness, two very different blast radiiA stale entry at the shared tier is served to everyone routed through it, while a stale entry in a browser affects a single user who can force a refresh.Stale at the shared tiereveryone in the regionEvery request routed to that node receives the old bodyThe user has no way to force a refresh past itDuration is the full remaining grace windowStale in the browserone user, self-correctableAffects only the profile holding the entryA reload sends no-cache and bypasses itDuration is usually a single navigation
Why the two tiers deserve separate answers

Step 2 — Send the narrower directive where it is sufficient

For content that many users share, where the edge must always hold verified data but a single browser falling back to its own copy is harmless:

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

For content where a stale value could cause a wrong decision regardless of who sees it:

Cache-Control: private, max-age=15, must-revalidate
ETag: "acct-9471-r8823"
Which directive to send, by content classThe choice follows from whether a stale value is merely untidy or actually harmful at each tier.DirectiveReasonAccount balancemust-revalidatewrong figure drives a wrong actionInventory countmust-revalidateoversell risk at any tierShared dashboard fragmentproxy-revalidateedge strict, browser tolerantMarketing pageneitherstaleness is harmless, resilience preferredFingerprinted assetneithercontent cannot change under the URL
Five content classes and the directive each needs

Step 3 — Verify the shared tier

This is the only test that exercises the directive, and it requires the origin to be unreachable while a stored entry is expired. In staging:

# Warm the entry
curl -sI https://staging.example.com/fragments/summary | grep -iE '^age|cache-control'

# Wait past s-maxage, stop the origin, then request again
sleep 130
curl -s -o /dev/null -w '%{http_code}\n' https://staging.example.com/fragments/summary
# Expected with proxy-revalidate or must-revalidate: 504
# Expected without either: 200, served stale

A 200 where you expected 504 means something is overriding the directive — a vendor grace period, an always-online feature, or a proxy that does not implement it.

Step 4 — Verify the browser tier

The two directives diverge here, and this is the test that proves you chose the one you meant. With a stored, expired entry in a browser profile and the origin unreachable:

  • Under must-revalidate, the browser must not reuse the entry and the navigation fails.
  • Under proxy-revalidate, the browser is free to serve its own copy and the page renders from cache.
The divergence, with the origin unreachableWith an expired entry and no reachable origin, must-revalidate produces an error at both tiers while proxy-revalidate lets the browser answer from its own copy.BrowserEdgeOriginGET, entry expired at both tiersrevalidation attemptunreachable504 under either directiveproxy-revalidate: serve own stale copy
Same outage, two different outcomes for the user

Step 5 — Record the availability consequence

Whichever directive you send, the route now has an availability ceiling set by the origin. That belongs in the same dashboard as the origin’s error rate, because the failure mode is specific and easy to miss: overall site availability stays healthy while a small number of strict routes return 504 during an origin incident.

The practical form is a per-route error-rate panel that separates 504 responses generated by the cache from 5xx responses proxied from the origin. The two look identical in a total error count and mean very different things — the first is the directive working as designed, the second is the origin failing. Teams that do not separate them tend to discover the distinction during an incident, when the difference between “the cache is refusing to serve stale data” and “the application is broken” matters most.

It is also worth reviewing which routes carry the directive on a schedule. Strictness accumulates: a route marked strict during an incident three quarters ago is rarely revisited, and each one adds an origin dependency that was justified once and may not be now.

Expected Output / Verification

A correct configuration produces three consistent observations: free reuse for the whole lifetime, an error rather than stale content at the shared tier once expired and the origin unavailable, and browser behaviour that matches the directive you chose rather than the one you assumed.

# Within the lifetime — reuse, no origin contact
HTTP/2 200
Age: 74
Cache-Control: public, max-age=30, s-maxage=120, proxy-revalidate

# Expired, origin unreachable — the shared tier refuses to serve stale
HTTP/2 504

Edge Cases

  • An intermediary that ignores proxy-revalidate. Support is thinner than for must-revalidate. If a tier ignores it, that tier silently retains its discretion to serve stale, which is precisely the outcome the directive was chosen to prevent.
  • A vendor grace period overriding both. Fastly’s beresp.grace and equivalent settings elsewhere authorise stale serving independently of the response header. Set grace to zero for strict routes rather than assuming the directive wins.
  • proxy-revalidate on a private response. The combination is inert: a private response is never stored by a shared cache, so a directive constraining shared caches has nothing to act on.
  • Choosing must-revalidate for a route with no validator. Every post-expiry request becomes a full refetch rather than a 304, turning a cheap correctness guarantee into a bandwidth cost that scales with traffic.
  • Forgetting that both are inert while fresh. A team adds the directive expecting immediate revalidation, observes no change, and concludes it is unsupported — when in fact the lifetime simply had not elapsed.

Frequently Asked Questions

Is proxy-revalidate widely supported?

Support in CDNs and reverse proxies is good, but it is far less commonly implemented than must-revalidate and some intermediaries ignore it. Verify against the specific tiers in your path rather than assuming, and treat an unverified directive as advisory.

Can I send both directives together?

You can, and it is harmless but redundant: must-revalidate already binds shared caches, so adding proxy-revalidate changes nothing. Sending both usually indicates uncertainty about which one was needed.

Does proxy-revalidate stop the edge storing the response?

No. It only removes the edge’s discretion to serve an expired copy. If the response must not be stored at the shared tier at all, that is private, and if it must not be stored anywhere it is no-store.

Which one should an authenticated API use?

Usually must-revalidate together with private, because a stale authenticated response is a correctness problem in the browser as much as at the edge. proxy-revalidate suits shared content where a single user’s brief staleness is harmless.


Back to must-revalidate and proxy-revalidate