Combining must-revalidate with stale-while-revalidate
Problem Statement
A header carries both must-revalidate and stale-while-revalidate=60. One says an expired response must never be reused without revalidation; the other says it may be reused for a minute past expiry. RFC 9111 does not say which wins, so the answer depends on which cache is in the path — and it can differ between the edge and the browser handling the same request.
Prerequisite Concepts
- must-revalidate and proxy-revalidate explained — what strictness after expiry actually requires.
- Serving stale content with stale-while-revalidate — the grace window and how it is meant to behave.
- Freshness vs validation models explained — the two reuse models the contradiction sits between.
Step-by-Step Resolution
Step 1 — Confirm the pair is really being emitted
Contradictory headers are usually assembled rather than authored: one layer adds a grace directive, another appends strictness, and neither is aware of the other.
# At the origin, bypassing the CDN
curl -sI --resolve example.com:443:203.0.113.10 https://example.com/api/summary \
| grep -i cache-control
# Through the CDN
curl -sI https://example.com/api/summary | grep -i cache-control
If the pair appears only in the second capture, an edge rule is adding one of them, and the fix belongs there rather than in the application.
Step 2 — Determine which one your cache honours
The test is behavioural. Warm an entry, wait until just past the lifetime but inside the grace window, and time the response:
curl -sI https://example.com/api/summary | grep -iE '^age|cache-control'
sleep 35 # past max-age=30, inside stale-while-revalidate=60
curl -s -o /dev/null -w 'status %{http_code} time %{time_total}s\n' \
https://example.com/api/summary
A response returned in a few milliseconds means the grace window won: the cache answered from storage. A response taking as long as an origin round trip means must-revalidate won and the request blocked on revalidation.
Step 3 — Decide which behaviour the route needs
The choice is the same trade covered throughout this section: is an out-of-date value worse than a slow response or an error?
The last row is worth highlighting, because it is what most teams sending the contradictory pair actually wanted: strictness where the user is, and a grace window at the shared tier where a blocking revalidation would affect everyone at once.
Step 4 — Remove the contradiction at its source
Fix the layer that appends the second directive rather than overwriting the whole header downstream, which merely moves the ambiguity:
sub vcl_backend_response {
# Never emit both; the grace directive is the one this service wants
if (beresp.http.Cache-Control ~ "must-revalidate"
&& beresp.http.Cache-Control ~ "stale-while-revalidate") {
set beresp.http.Cache-Control =
regsub(beresp.http.Cache-Control, ",?\s*must-revalidate", "");
}
}
Step 5 — Add a check that catches it next time
Contradictory headers reappear whenever a new layer is added to the response path. A one-line assertion in the release checks is enough:
curl -sI https://staging.example.com/api/summary \
| grep -i cache-control \
| grep -qE 'must-revalidate.*stale-while-revalidate|stale-while-revalidate.*must-revalidate' \
&& { echo "contradictory revalidation directives"; exit 1; }
Expected Output / Verification
After the fix, exactly one of the two directives appears, and the timing test produces a consistent result across repeated runs. A route configured for a grace window answers from storage in single-digit milliseconds just past expiry; a strict route takes an origin round trip and never serves an unverified body.
HTTP/2 200
Cache-Control: public, max-age=0, s-maxage=30, stale-while-revalidate=60
Age: 41
An Age above s-maxage on a fast response confirms the grace window is active and unambiguous.
Edge Cases
- The edge and the browser resolving it differently. A browser applying strictness while the edge applies grace produces a page whose subresources behave inconsistently, which is difficult to reproduce and easy to blame on the network.
- A vendor grace setting adding a third opinion.
beresp.graceand equivalents authorise stale serving independently of the header, so removing the contradiction from the header does not necessarily remove it from the behaviour. stale-if-erroralongsidemust-revalidate. The same contradiction, narrowed to the origin-failure case. It is the pair most likely to survive review because the conflict only manifests during an incident.- A framework default appending
must-revalidate. Several server frameworks add it automatically to responses they consider dynamic. The application never authored it, so it will not be found by searching the application code. - A grace window shorter than the origin’s recovery time. A sixty-second window is only useful if the refresh behind it can complete. When the origin is slow rather than down, the window closes before the refresh returns and every subsequent request blocks anyway, which reads as the strict behaviour even though the grace directive is the one being honoured.
- Removing the wrong directive downstream. Stripping
stale-while-revalidateat the edge fixes the edge and leaves browsers with a header the origin never intended. Fix it once, at the layer that emits it.
Frequently Asked Questions
Which directive wins when both are sent?
There is no specified answer. Some implementations treat must-revalidate as the stricter, overriding rule; others apply the grace window because it is more specific about the stale period. Behaviour also differs between a CDN and a browser in the same request path.
Is there any legitimate reason to send both?
No. Whatever intent the pair is meant to express can be stated unambiguously with one directive plus a lifetime. If the goal is a grace window at the edge but strictness in the browser, use s-maxage with the grace directive and a short max-age instead.
Does stale-if-error conflict in the same way?
Yes, for the same reason: it authorises serving an expired response that must-revalidate forbids. The conflict is narrower — it applies only when the origin is failing — but it is the same contradiction.
How do I express strict-at-origin-failure but relaxed otherwise?
That combination is not expressible, because the grace directives exist precisely to relax the origin-failure case. Choose which property matters more for the route; they are genuinely mutually exclusive.
Related
- Serving stale content with stale-while-revalidate covers the grace directive in full, including the windows it creates.
- Header stacking and directive precedence explains how caches resolve directives that do have a defined ordering.
- How to combine Cache-Control directives safely gives the general method for checking a pair before shipping it.