Surrogate-Control vs Cache-Control Precedence
Problem Statement
The edge should hold a page for a day while browsers hold it for a minute. s-maxage expresses that, but it also publishes the day-long edge lifetime to every client and to any intermediary in between. Surrogate-Control expresses the same split without publishing it: the edge reads it, acts on it, and removes it before the response continues downstream.
Prerequisite Concepts
- Mastering
max-ageands-maxagedirectives — the portable two-lifetime mechanism this is an alternative to. - Header stacking and directive precedence — how competing directives resolve.
- Public vs private cache scope — the scope decision that sits alongside the lifetime one.
Step-by-Step Resolution
Step 1 — Decide whether the edge lifetime should be public
s-maxage is simpler and works everywhere, so the question is whether there is a reason to hide the edge lifetime. Three reasons come up in practice: the lifetime reveals something about internal architecture, an intermediary between the edge and the client would misapply it, or the edge needs directives that would be actively harmful if a browser honoured them.
Step 2 — Emit the edge header
HTTP/2 200
Surrogate-Control: max-age=86400, stale-while-revalidate=60
Cache-Control: public, max-age=60
ETag: "9f21-8c4"
The edge reads Surrogate-Control and holds the object for a day. The browser never sees that line and applies max-age=60 from the Cache-Control header.
sub vcl_backend_response {
if (beresp.http.Surrogate-Control) {
# Fastly applies it automatically and removes it from the client response
return (deliver);
}
}
Step 3 — Understand the precedence
The rule is specificity: at a surrogate that understands it, Surrogate-Control wins over s-maxage, which in turn wins over max-age. At every other cache, Surrogate-Control does not exist and normal Cache-Control precedence applies unchanged.
Step 4 — Confirm the header is stripped
A leaked Surrogate-Control is a configuration smell and occasionally a disclosure:
# At the origin — the header should be present
curl -sI --resolve example.com:443:203.0.113.10 https://example.com/products/9472 \
| grep -iE 'surrogate-control|cache-control'
# Through the CDN — Surrogate-Control should be gone
curl -sI https://example.com/products/9472 \
| grep -iE 'surrogate-control|cache-control'
If the header survives to the client, either the CDN does not support it — in which case it is doing nothing at all — or it is configured to pass it through, which should be corrected.
Step 5 — Verify each tier independently
# The edge should hold the object far longer than the browser lifetime
curl -sI https://example.com/products/9472 | grep -iE '^age|cache-control'
An Age well above the max-age in Cache-Control, on a HIT, is the confirmation that the edge is applying the surrogate lifetime. Without Surrogate-Control in play that combination would indicate a bug; here it is exactly the intended behaviour.
Step 6 — Weigh it against simply using s-maxage
For most sites s-maxage remains the better default, and it is worth being honest about when the surrogate header earns its complexity.
The case against it is portability. A configuration that depends on Surrogate-Control behaves differently the moment the CDN changes, and the failure is silent: the edge quietly falls back to the client-facing lifetime, origin traffic rises, and nothing errors. That failure mode is particularly awkward during a migration, when many other things are changing at once and a hit-ratio drop is easy to attribute elsewhere.
The case for it is narrow but real. When an intermediary sits between your edge and the client — a corporate proxy, a partner’s CDN, an ISP cache — a long s-maxage is an instruction that intermediary will follow, and you have no purge path to it. Keeping the long lifetime invisible means only the cache you control holds the object for a day. That is a genuine architectural reason rather than a stylistic preference, and it is the one that justifies the extra header.
If neither of those applies, s-maxage does the same job with fewer moving parts.
Expected Output / Verification
A correct deployment shows the surrogate header at the origin, its absence at the client, and an Age value that exceeds the client-facing lifetime:
origin: Surrogate-Control: max-age=86400 Cache-Control: public, max-age=60
client: Cache-Control: public, max-age=60 Age: 41230 CF-Cache-Status: HIT
Because the arrangement depends on vendor behaviour rather than on the specification, it deserves an explicit check in the release pipeline rather than a one-time verification.
Edge Cases
- A CDN that ignores it. The edge then falls back to
Cache-Control, and amax-age=60intended for browsers becomes the edge lifetime too. Always ensure theCache-Controlfallback is safe on its own. - A second surrogate in the path. With two CDNs chained, the first consumes the header and the second never sees it, so the second applies
Cache-Controlinstead. - A leaked header downstream. Intermediaries do not act on it, but it reveals internal policy and can confuse debugging. Strip it explicitly if the vendor does not.
- Directives with no browser equivalent. Some surrogate directives have no meaning to a browser at all, which is safe — but the reverse mistake, putting a browser-relevant directive only in the surrogate header, silently loses it.
- Vendor-specific extensions. Akamai’s
Edge-Controland Fastly’sSurrogate-Controloverlap but are not identical. A multi-CDN setup needs both, kept in sync. - Debugging confusion. An
Ageabovemax-agenormally signals a bug. With a surrogate lifetime in play it is expected, so anyone reading captures needs to know the arrangement exists.
Frequently Asked Questions
Does Surrogate-Control override s-maxage?
Where a vendor supports it, yes — it is more specific and takes precedence at the edge. Cache-Control still governs every cache that does not understand Surrogate-Control, including browsers, which is the whole point of the split.
Which vendors support it?
Fastly and Akamai consume it directly; Akamai also offers Edge-Control for the same purpose. Cloudflare does not read it by default and requires a rule or a Worker to act on it, so a portable configuration cannot depend on it.
Is the header removed before reaching the browser?
It should be. A conforming surrogate consumes and strips it, which is what keeps the edge lifetime invisible to clients. Verify this, because a leaked header reveals internal caching policy and confuses downstream caches.
Why not just use s-maxage?
For most sites s-maxage is sufficient and more portable. Surrogate-Control is worth reaching for when the edge lifetime must not be visible to browsers, or when the edge needs directives that would be harmful if a browser applied them.
Related
- Mastering max-age and s-maxage directives covers the portable way to express two lifetimes in one header.
- Surrogate-Key vs Cache-Tag across CDN vendors covers the related vendor-specific header family for invalidation.
- Cache debugging and observability shows how to capture what each tier actually received.