Cache-Control & CDN TTL
This guide explains how HTTP Cache-Control directives negotiate freshness with the two distinct caches in your delivery path — the visitor’s browser and the shared CDN edge — and how each CDN derives the edge TTL it actually enforces.
Key implementation points:
- A single origin response can carry two different lifetimes:
max-agegoverns the private browser cache whiles-maxage(or a CDN-specific surrogate header) governs the shared edge cache. - CDNs honor a strict precedence order:
CDN-Cache-ControlandSurrogate-Controloverrides-maxage, which overridesmax-age, which is overridden in turn by any dashboard/Cache-Rule TTL you set on the edge. - Edge TTL and browser TTL are computed independently — clamping origin values, substituting defaults when headers are missing, and stripping surrogate headers before the response leaves the edge.
no-cache,no-store,private,must-revalidate, andimmutablechange whether and how revalidation happens, not just how long an object stays fresh.
Two caches, two clocks
Every cacheable response travels through at least two caches that keep separate freshness clocks. The browser keeps a private cache scoped to one user; a CDN keeps a shared cache that serves the same stored object to thousands of users. Cache-Control is the one header that addresses both, and the trick is that several of its directives are aimed at only one of them.
max-age=N tells every cache the object is fresh for N seconds — but s-maxage=N overrides max-age for shared caches only, leaving the browser on the max-age value. So a response of Cache-Control: public, max-age=60, s-maxage=86400 means “browsers revalidate after a minute, the CDN holds it for a day.” This split is the single most useful pattern in CDN tuning: short client TTLs keep users from pinning stale HTML, while long edge TTLs keep your origin idle. The companion guide on setting Cache-Control headers for static and dynamic content walks through concrete header recipes per content type.
public and private decide who may store the response. private confines the object to the browser cache and forbids shared CDN storage — essential for personalized pages. public explicitly permits shared caching even for responses a cache would normally treat as private (for example, responses to authenticated requests). Note that a CDN may still refuse to cache a private response, and some CDNs cache public responses that lack any freshness directive by applying a default TTL.
no-cache vs no-store, must-revalidate, immutable
These four directives are routinely confused, and the difference is operationally significant:
| Directive | Stored? | Served without revalidation? | Use case |
|---|---|---|---|
no-store |
Never | Never | Secrets, banking, PII responses |
no-cache |
Yes | No — must revalidate every time | HTML that changes often but supports ETag |
must-revalidate |
Yes | Only while fresh; once stale, must revalidate | Strict correctness, no stale serving |
immutable |
Yes | Yes, never revalidates while fresh | Fingerprinted assets (app.4f3a.js) |
no-store is an absolute prohibition: the response must not be written to any cache, period. no-cache is the misleading one — it does store the object but requires a successful revalidation (conditional If-None-Match/If-Modified-Since) before reuse. must-revalidate forbids serving a stale copy once the freshness lifetime expires, which interacts directly with stale-serving strategies covered in serving stale content with stale-while-revalidate. immutable is a performance escape hatch: it tells the browser not to send a revalidation request even on a hard reload, which is ideal for content-hashed bundles that never change under a given URL.
Header precedence: who wins the TTL fight
When multiple freshness signals are present, CDNs resolve them in a fixed order. From most specific to least:
- Edge-side configuration (Cloudflare Cache Rules “Edge TTL”, CloudFront
CachePolicymin/default/max, a Fastly VCL override ofberesp.ttl) — if set to ignore origin, this wins outright. CDN-Cache-Control— a targeted header read only by CDNs and stripped before the browser sees it.Surrogate-Control— the older Edge Architecture header (Fastly, Akamai, Varnish) with the same intent.s-maxage— shared-cache directive insideCache-Control.max-age— the general lifetime, used by the edge only when nothing more specific exists.
The vendor-neutral CDN-Cache-Control header is the cleanest way to separate edge policy from browser policy without VCL: emit Cache-Control: max-age=60 for browsers and CDN-Cache-Control: max-age=86400 for the edge, and each cache reads only its own header. Cloudflare, Fastly, and Akamai all honor it; CloudFront does not read it by default and uses its CachePolicy instead.
A critical hygiene rule: surrogate headers must never leak to the client. A well-behaved CDN strips Surrogate-Control and CDN-Cache-Control from the response before forwarding it. If you see those headers in a browser network tab, your CDN is misconfigured (or you are looking at a cache bypass).
Provider-specific implementation
Cloudflare
Cloudflare splits the lifetime into Edge Cache TTL and Browser Cache TTL, both configurable via Cache Rules. By default Cloudflare respects the origin’s s-maxage/max-age for eligible content types, but Cache Rules let you override edge TTL independently of what the origin sends. The modern approach uses a Cache Rule rather than legacy Page Rules:
{
"description": "Long edge TTL for static assets, short browser TTL",
"expression": "(http.request.uri.path matches \"\\\\.(js|css|woff2|png|jpg|svg)$\")",
"action": "set_cache_settings",
"action_parameters": {
"cache": true,
"edge_ttl": {
"mode": "override_origin",
"default": 2592000
},
"browser_ttl": {
"mode": "override_origin",
"default": 86400
}
}
}
mode: "respect_origin" makes Cloudflare derive edge TTL from CDN-Cache-Control → Surrogate-Control → s-maxage → max-age (in that order), while override_origin ignores those headers entirely. Cloudflare honors CDN-Cache-Control for edge freshness when you keep the respect-origin mode, which is the recommended way to keep policy in your application code.
AWS CloudFront
CloudFront derives edge TTL from a three-way clamp defined in the attached Cache Policy: MinTTL, DefaultTTL, and MaxTTL. The origin’s Cache-Control: max-age/s-maxage is honored only within that window. If the origin sends max-age=300 but the policy sets MinTTL=3600, CloudFront caches for 3600 seconds — the floor wins. If the origin sends no caching headers, DefaultTTL applies.
resource "aws_cloudfront_cache_policy" "static_assets" {
name = "static-assets-policy"
min_ttl = 0
default_ttl = 86400
max_ttl = 31536000
parameters_in_cache_key_and_forwarded_to_origin {
enable_accept_encoding_brotli = true
enable_accept_encoding_gzip = true
cookies_config { cookie_behavior = "none" }
headers_config { header_behavior = "none" }
query_strings_config { query_string_behavior = "none" }
}
}
The clamp behavior is the most common source of “why is my cache not respecting max-age?” tickets. Set MinTTL=0 and a generous MaxTTL if you want the origin’s headers to drive freshness. The headers and cookies you forward also define the cache key — see cache key & vary configuration for how that interacts with hit ratio.
Fastly
Fastly is VCL-native and reads Surrogate-Control for edge TTL, falling back to Cache-Control s-maxage, then max-age. In vcl_fetch you can read or override beresp.ttl directly:
sub vcl_fetch {
# Honor Surrogate-Control: max-age first, else default to 1h at the edge.
if (beresp.http.Surrogate-Control !~ "max-age") {
set beresp.ttl = 3600s;
}
# Strip surrogate headers so they never reach the browser.
unset beresp.http.Surrogate-Control;
# Keep a short browser lifetime independent of the edge TTL.
set beresp.http.Cache-Control = "public, max-age=60";
return(deliver);
}
beresp.ttl is authoritative for the Fastly edge regardless of what Cache-Control says, so VCL gives you the finest-grained control. Fastly also supports stale-while-revalidate and stale-if-error through beresp.stale_while_revalidate and beresp.stale_if_error, which pairs with the resilient-caching patterns linked above.
Comparison table
| Provider | Edge TTL mechanism | Wire behavior (headers read) | Failover / notes |
|---|---|---|---|
| Cloudflare | Cache Rules: Edge Cache TTL + Browser Cache TTL | CDN-Cache-Control → Surrogate-Control → s-maxage → max-age (respect mode) |
override_origin ignores origin headers; strips surrogate headers |
| AWS CloudFront | Cache Policy: Min/Default/Max TTL clamp | s-maxage/max-age clamped to [Min, Max]; DefaultTTL if absent |
Does not read CDN-Cache-Control by default |
| Fastly | beresp.ttl (VCL) |
Surrogate-Control → s-maxage → max-age |
beresp.ttl overrides everything; native SWR/SIE |
| Akamai | Caching behavior + Edge-Control |
Edge-Control/Surrogate-Control → Cache-Control |
Honors CDN-Cache-Control; metadata-driven TTL |
Step-by-step configuration procedure
This procedure sets a short browser TTL and a long edge TTL for static assets, the highest-leverage default for most sites.
-
Decide the two lifetimes. Pick a browser
max-ageshort enough that a bad deploy clears quickly (60–300s for HTML, up to a year for fingerprinted assets) and an edge TTL long enough to keep origin idle (hours to days). -
Emit the headers from the origin. Have your app or web server send both lifetimes. Example for a fingerprinted bundle:
curl -sI https://example.com/static/app.4f3a9c.js | grep -i cache # cache-control: public, max-age=31536000, immutable # cdn-cache-control: max-age=31536000 -
Set or confirm the edge policy. On Cloudflare keep the Cache Rule in
respect_originmode (or override as shown above). On CloudFront setMinTTL=0, a sensibleDefaultTTL, and aMaxTTLceiling. On Fastly verifyberesp.ttlis not being force-set in VCL. -
Verify what the edge enforces. Request twice and read the cache-status and age:
curl -sI https://example.com/static/app.4f3a9c.js \ | grep -iE 'cf-cache-status|x-cache|age|cache-control' # cf-cache-status: HIT # age: 142 # cache-control: public, max-age=31536000, immutableA growing
Ageon repeated requests confirms the edge is serving from cache.cf-cache-status: HIT(Cloudflare),X-Cache: Hit from cloudfront, orX-Cache: HIT(Fastly) confirm an edge hit. -
Confirm surrogate headers are stripped. The browser response must not contain
Surrogate-ControlorCDN-Cache-Control. If it does, your CDN is not processing them.
TTL, propagation, and caching implications
Edge TTL behaves like a DNS TTL in one important respect: once an object is cached with a long TTL, lowering the header value at the origin does not shorten the lifetime of already-cached copies. The new TTL only applies on the next origin fill. This is why a long edge TTL plus a deploy that changes content under a stable URL produces stale content until either the TTL expires or you actively purge.
The standard mitigation is the same pattern used for safe DNS cutovers: lower the TTL before you need agility, not during the incident. For content that changes on deploy, prefer content-hashed URLs (app.4f3a.js) with immutable so the URL itself changes and no purge is needed; reserve active purging for stable-URL content like HTML and API responses.
Browser TTL has the harshest propagation profile of all: there is no purge API for browser caches. Whatever max-age a client stored, it keeps until expiry or a hard reload. Keeping browser max-age small (or relying on no-cache with revalidation) is the only lever you have over already-distributed clients, which is the strongest argument for the short-browser / long-edge split.
Age accounting matters when chaining caches. Each cache adds elapsed seconds to the Age header, and a downstream cache treats the object as fresh only for (s-maxage − Age) more seconds. A response that sat 3500 seconds at the edge with s-maxage=3600 has just 100 seconds of freshness left for anything downstream.
Troubleshooting and rollback
| Symptom | Likely cause | Fix |
|---|---|---|
| Stale content after deploy | Long edge TTL, stable URL, no purge | Purge the path via API; switch to hashed URLs + immutable |
max-age ignored at edge |
CloudFront MinTTL floor or Cloudflare override mode |
Set MinTTL=0; switch Cache Rule to respect-origin |
| Object never cached | private, no-store, Set-Cookie, or missing freshness + default-off CDN |
Send public + explicit s-maxage; remove Set-Cookie on cacheable paths |
| Surrogate header visible in browser | CDN not processing it (wrong header name or bypassed) | Verify exact header spelling; confirm request hit the edge not origin directly |
| Personalized data served to wrong user | public on a per-user response |
Switch to private, no-store; check the cache key includes the auth dimension |
Rollback protocol when a bad TTL ships:
- Stop the bleeding at the origin. Change the origin header to a short or zero TTL (
Cache-Control: public, max-age=0, s-maxage=0orno-cache) so all future fills are short-lived. - Purge the affected paths. New origin headers do not retroactively shorten already-cached objects; purge by URL, prefix, or tag. See the cache purging & invalidation guide for the API calls.
- Verify with
Age. After purge, the first request should showAge: 0and aMISS/EXPIREDstatus, then climb again. - Restore the intended TTL only after confirming the content is correct, and re-test the HIT path.
Edge cases and gotchas
- A
Set-Cookieon a response makes most CDNs treat it as private and skip the shared cache unless you explicitly strip the cookie at the edge. s-maxageimpliesproxy-revalidatesemantics on some caches — a stale shared object must revalidate, which can surprise you if the origin is down. Pair it withstale-if-errorfor resilience.max-age=0andno-cacheare not equivalent:max-age=0permits serving after a successful revalidation;no-cacherequires revalidation every time but is otherwise similar. Neither prevents storage — onlyno-storedoes.- CloudFront ignores
CDN-Cache-Controlunless you add it to the origin response and rely onCache-Controlsemantics instead; do not assume cross-CDN header parity. immutableis ignored by some older browsers, which fall back to normal revalidation — harmless, but do not rely on it as your only freshness control.- Vary on uncontrolled headers (like
User-Agent) fragments the cache and collapses hit ratio; constrain the cache key deliberately.
Frequently Asked Questions
What is the difference between max-age and s-maxage?
max-age sets the freshness lifetime for every cache, including the browser. s-maxage overrides max-age for shared caches such as a CDN edge only, leaving the browser on the max-age value. Use them together to give browsers a short TTL and the edge a long one.
Does CDN-Cache-Control work on every CDN?
No. Cloudflare, Fastly, and Akamai read CDN-Cache-Control and give it precedence over s-maxage and max-age. AWS CloudFront does not read it by default and derives edge TTL from its Cache Policy Min/Default/Max clamp, so you must set TTL there instead.
Why does my CDN ignore the max-age my origin sends?
The most common cause is an edge-side floor or override: CloudFront’s MinTTL clamps short origin values up to the floor, and a Cloudflare Cache Rule in override_origin mode ignores origin headers entirely. Set MinTTL=0 or switch to respect-origin mode so the origin headers drive freshness.
If I lower the TTL at the origin, do cached copies expire sooner?
No. A lower TTL only applies to objects fetched after the change. Copies already cached keep their original lifetime until it expires. To shorten already-cached content immediately you must purge it; for browser caches there is no purge, so they keep their stored max-age until expiry or a hard reload.