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.
How a cache computes the freshness lifetime
A cache does not simply read max-age and start a stopwatch. On every lookup it derives two quantities and compares them: the stored response’s freshness lifetime and its current age. The lifetime comes from the first directive present, in order — s-maxage for shared caches, then max-age, then Expires minus the response Date. The age begins as the gap between the cache’s clock and that same Date at the moment of storage, then accumulates every second the object sits in storage, plus whatever Age an upstream cache already declared. While age is below lifetime the object is fresh; the instant that flips, the cache must revalidate, serve stale under an explicit allowance, or fetch a new copy.
That arithmetic explains two behaviors engineers routinely misread. First, an object can arrive at your edge already half-spent, because a shield or upstream proxy held it and passed its accumulated Age along. Second, clock skew between origin and edge silently distorts every TTL you configure, since Date is the origin’s opinion of “now”. A web server running three minutes fast hands every shared cache three minutes of free freshness; one running slow burns three minutes off every object it emits. Keeping NTP disciplined on origin hosts is a caching concern, not only a logging one.
When no freshness directive exists at all, RFC 9111 permits heuristic freshness: the cache may invent a lifetime, conventionally a tenth of the interval between Last-Modified and Date. Browsers do this routinely, which is why an unversioned image served with no headers can stubbornly persist in a user’s cache for hours after you replaced it. Most CDNs disable heuristics in favor of a configured default TTL, but the browser side is beyond your reach — the only reliable defense is to emit an explicit directive on every response, even when that directive is max-age=0.
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.
Azure Front Door
Front Door Standard and Premium express edge lifetime through a rules-engine action rather than a standalone policy object. The CacheExpiration action offers three modes that map cleanly onto the choices above: BypassCache refuses to store, Override discards the origin’s freshness directives and applies your duration, and SetIfMissing applies your duration only when the origin sent nothing usable. Front Door reads Cache-Control and Expires, prefers s-maxage over max-age, and ignores CDN-Cache-Control entirely.
az afd rule create \
--resource-group edge-rg --profile-name edge-profile \
--rule-set-name assets --rule-name longEdgeTtl --order 1 \
--match-variable UrlFileExtension --operator Equal \
--match-values js css woff2 \
--action-name CacheExpiration \
--cache-behavior SetIfMissing --cache-duration 30.00:00:00
Two Front Door behaviors catch teams out. A response carrying Set-Cookie is never stored, no matter what the rules engine says, so a stray analytics cookie on a static route quietly disables caching for it. And query strings are excluded from consideration until you change the route’s query-string caching behavior, which means a versioned URL like app.js?v=7 will serve the old body until you opt in.
Google Cloud CDN
Cloud CDN attaches its policy to a backend service, and the cacheMode field decides how much of the origin’s opinion survives. USE_ORIGIN_HEADERS demands a valid freshness directive and stores nothing without one. CACHE_ALL_STATIC caches recognized static content types under a default TTL while still honoring explicit origin directives. FORCE_CACHE_ALL overrides everything — including responses marked private — which makes it actively dangerous on any backend that can emit per-user content.
gcloud compute backend-services update web-backend \
--global \
--cache-mode=CACHE_ALL_STATIC \
--default-ttl=3600 \
--max-ttl=86400 \
--client-ttl=60
--client-ttl has no direct equivalent among the other providers listed here: it rewrites the max-age the browser receives while leaving the edge lifetime on --default-ttl, delivering the short-browser / long-edge split without changing a line of origin code. Note that --client-ttl can only lower what the origin sent, never raise it.
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 |
| Azure Front Door | Rules engine CacheExpiration action |
s-maxage → max-age → Expires; no CDN-Cache-Control |
Set-Cookie disables caching outright; query strings opt-in |
| Google Cloud CDN | Backend service cacheMode + TTL fields |
Origin headers honored unless FORCE_CACHE_ALL |
--client-ttl rewrites browser max-age independently |
Choosing the two numbers
Precedence tells you which header wins; it does not tell you what value to put in it. The useful discipline is to derive both lifetimes from a single question per content class: how long can a user look at a stale copy of this without anyone noticing or caring? That answer becomes the browser number. The edge number is then almost always much larger, bounded only by how quickly you can purge.
| Content class | Browser lifetime | Edge lifetime | Reasoning |
|---|---|---|---|
Fingerprinted bundle (app.4f3a.js) |
max-age=31536000, immutable |
one year | The URL changes when the bytes change, so staleness is impossible |
| Unhashed logo or favicon | max-age=86400 |
one week | Rarely edited; a day of client staleness is tolerable |
| Marketing HTML | max-age=0 |
60–300s | Must reflect a deploy quickly; the edge absorbs the traffic |
| Catalog or search JSON | max-age=0 |
30–120s | Volatile but shared; pair with stale-while-revalidate |
| Per-user dashboard | private, no-store |
never cached | Correctness beats performance without exception |
The asymmetry is deliberate. A browser cache you cannot purge should hold nothing you might need to change; an edge cache you can purge in under a second should hold everything for as long as possible. Teams that get this backwards — long browser TTLs, short edge TTLs — end up with both a high origin load and users stuck on last week’s CSS, which is the worst of both.
One number deserves special care: the edge lifetime for HTML. Setting it to zero throws away the single biggest offload opportunity on most sites, because HTML is requested more often than any individual asset. Setting it to hours makes every deploy dependent on a flawless purge. Sixty seconds is the value that survives contact with production: short enough that a missed purge self-heals within a minute, long enough that a traffic spike is absorbed at the edge rather than at your application servers.
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.
Scenario: the TTL is correct but origin traffic keeps climbing
This is the failure mode that survives every header audit, because nothing in the headers is wrong. The response says s-maxage=86400, the edge reports HIT when you test it, and yet origin request volume grows week over week. Three causes account for almost all of these.
The first is variant explosion. Edge TTL applies per stored object, and every distinct cache key is a separate object with its own cold start. If a new tracking parameter, a new language code, or an accidental Vary header entered the response last month, your object count multiplied and each new variant now pays its own origin fill. Count distinct cached objects, not hit ratio, when the ratio looks stable but volume does not.
The second is eviction under storage pressure. A TTL is permission to keep an object, not a guarantee. When a POP’s storage fills, least-recently-used objects are dropped long before their lifetime expires, so rarely-requested content behaves as if it had a much shorter TTL. Symptoms are regional: the same URL is a reliable HIT in your busiest POP and a reliable MISS in a quiet one. An origin shield fixes this properly, because the shield’s storage serves every downstream POP.
The third is revalidation without a validator. If the origin emits no ETag and no Last-Modified, every expiry is a full body transfer rather than a 304, so origin bandwidth climbs even when request count does not. Check egress bytes separately from request count; the shapes of these two curves tell you which cause you are looking at.
# Does the origin emit a validator at all? No ETag means no 304s, ever.
curl -sI https://example.com/static/hero.jpg | grep -iE 'etag|last-modified'
# Compare the same URL across two POPs: a regional split points at eviction
curl -sI --resolve example.com:443:198.51.100.10 https://example.com/ | grep -i cf-cache-status
curl -sI --resolve example.com:443:203.0.113.10 https://example.com/ | grep -i cf-cache-status
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. - A
Cache-Controlvalue assembled by string concatenation in application middleware is a recurring source of duplicated or contradictory directives (max-age=60, max-age=0). Caches resolve conflicts inconsistently; build the header once, from one place, and assert on it in a test. Ageis only emitted by shared caches. Its absence on a response you believe was served from the edge usually means you reached the origin directly — check for a bypass rule or a hostname that is not proxied before you start editing TTLs.- Redirects are cacheable and frequently over-cached. A
301with the default lifetime can pin a bad redirect at the edge and in every browser that saw it; use302with an explicit short lifetime until the target is final. - Compressed variants each carry their own TTL countdown but share a purge. If you change compression settings, the old Brotli and gzip objects age out independently, so a partial rollout can serve two different encodings of two different builds; see edge compression for the negotiation details.
- Range requests on large media are stored as separate byte-range objects on some providers. A short TTL on a video file therefore multiplies origin fetches by the number of ranges a player requests, not by the number of viewers.
- An origin that answers
HEADdifferently fromGETwill confuse both your verification commands and the cache itself. Test withcurl -sIandcurl -s -o /dev/null -D -and compare the two before trusting either.
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.
What TTL should I use for HTML at the edge?
Sixty seconds is the value that holds up in production for most sites. It is short enough that a forgotten purge corrects itself within a minute, and long enough that the edge absorbs a traffic spike instead of forwarding it to your application servers. Pair it with max-age=0 for browsers so a user never holds the stale document, and with stale-while-revalidate so the refresh at expiry costs nobody a slow page.
Why does my response show a large Age immediately after a purge?
A purge that reached only one cache tier leaves the other holding the old object. When the edge refills from a shield or tiered cache that was not purged, it inherits both the stale body and the accumulated Age. Confirm that your purge call targets every tier — on providers with tiered caching this is usually automatic, but a manual per-POP purge or a regional API endpoint will not propagate upward.