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

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.

Two directives making incompatible promisesOne forbids reuse of an expired response entirely; the other authorises it for a defined window and refreshes behind the request.must-revalidateexpired means unusableReuse after expiry requires a successful 304An unreachable origin produces 504The user never sees an unverified valuestale-while-revalidateexpired means usable for a windowReuse after expiry is explicitly permittedThe refresh happens behind the responseThe user never waits for the origin
There is no behaviour that satisfies both

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.

The window where the two directives disagreeBoth directives behave identically while the response is fresh and after the grace window closes; the disagreement is confined to the window between them.Freshboth directives agree, reuse freely0sContested windowgrace says serve, strict says revalidate30sHard staleboth agree, revalidation required90s150s
max-age=30 with stale-while-revalidate=60: 60 seconds of ambiguity

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 unambiguous header for each intentEvery intent that the contradictory pair might have been reaching for is expressible with a single directive plus a lifetime.Header to sendBehaviour after expiryNever serve unverifiedpublic, max-age=30, must-revalidateblocks on revalidationNever make users waitpublic, max-age=30, stale-while-revalidate=60instant, refresh behindStrict, but survive an outagepublic, max-age=30, stale-if-error=3600strict normally, stale on failureStrict in browser, fast at edgemax-age=0, s-maxage=30, stale-while-revalidate=60browser revalidates, edge does not block
Four intents, four unambiguous headers

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.grace and 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-error alongside must-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-revalidate at 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.


Back to must-revalidate and proxy-revalidate