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

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.

Two ways to give the edge a different lifetimes-maxage is portable and visible to everyone; Surrogate-Control is vendor-specific and consumed before the client ever sees it.Cache-Control: s-maxageportable, visibleUnderstood by every shared cacheEdge lifetime visible to clients and intermediariesNo configuration required at the CDNSurrogate-Controlvendor-specific, strippedRead only by the surrogate that supports itRemoved before the response reaches the clientCan carry directives browsers should never see
The same split, with and without publishing it

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

Which header each tier readsThe edge prefers the surrogate header when it understands it; everything downstream sees only Cache-Control, because the surrogate header has already been consumed.READ FIRST AT THE EDGE1Surrogate-Controledge only, then stripped2Cache-Control s-maxageshared caches that see it3Cache-Control max-ageevery cache including browsersTHE ONLY HEADER THE CLIENT SEES
Two headers, read by different tiers

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.

Vendor support and behaviourSupport is uneven enough that a configuration depending on Surrogate-Control needs a documented fallback for vendors that ignore it.Reads itStrips itFallback neededFastlyAkamaiyes, plus Edge-ControlCloudflareonly via a rule or WorkerdependsNginx or Varnishno, unless scripted
Four platforms, three different behaviours

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 a max-age=60 intended for browsers becomes the edge lifetime too. Always ensure the Cache-Control fallback 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-Control instead.
  • 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-Control and Fastly’s Surrogate-Control overlap but are not identical. A multi-CDN setup needs both, kept in sync.
  • Debugging confusion. An Age above max-age normally 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.


Back to Header Stacking and Directive Precedence