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-age governs the private browser cache while s-maxage (or a CDN-specific surrogate header) governs the shared edge cache.
  • CDNs honor a strict precedence order: CDN-Cache-Control and Surrogate-Control override s-maxage, which overrides max-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, and immutable change whether and how revalidation happens, not just how long an object stays fresh.
Browser TTL vs Edge TTL vs Origin Freshness A request flows from browser to CDN edge to origin. The browser cache obeys max-age, the shared edge cache obeys s-maxage or surrogate headers, and the origin sets freshness. Surrogate headers are stripped before reaching the browser. Browser cache CDN edge cache Origin obeys max-age obeys s-maxage / CDN-Cache-Control / Surrogate-Control sets freshness via headers request miss fill serve strips surrogate headers, keeps max-age for client private, per-user shared, many users source of truth

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.

Directive behavior comparison Four rows compare no-store, no-cache, must-revalidate and immutable across three questions: is the object stored, may it be reused without asking the origin, and may a stale copy be served. What each directive actually permits Stored at the edge? Reuse without asking? Stale allowed? no-store secrets, PII, banking no never no no-cache HTML behind an ETag yes no, revalidate no must-revalidate strict correctness yes while fresh only no immutable hashed bundles yes yes, zero requests not applicable Only the first row forbids storage; the other three differ purely in how reuse is authorized.

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:

  1. Edge-side configuration (Cloudflare Cache Rules “Edge TTL”, CloudFront CachePolicy min/default/max, a Fastly VCL override of beresp.ttl) — if set to ignore origin, this wins outright.
  2. CDN-Cache-Control — a targeted header read only by CDNs and stripped before the browser sees it.
  3. Surrogate-Control — the older Edge Architecture header (Fastly, Akamai, Varnish) with the same intent.
  4. s-maxage — shared-cache directive inside Cache-Control.
  5. 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-ControlSurrogate-Controls-maxagemax-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-ControlSurrogate-Controls-maxagemax-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-Controls-maxagemax-age beresp.ttl overrides everything; native SWR/SIE
Akamai Caching behavior + Edge-Control Edge-Control/Surrogate-ControlCache-Control Honors CDN-Cache-Control; metadata-driven TTL
Azure Front Door Rules engine CacheExpiration action s-maxagemax-ageExpires; 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.

  1. Decide the two lifetimes. Pick a browser max-age short 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).

  2. 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
  3. Set or confirm the edge policy. On Cloudflare keep the Cache Rule in respect_origin mode (or override as shown above). On CloudFront set MinTTL=0, a sensible DefaultTTL, and a MaxTTL ceiling. On Fastly verify beresp.ttl is not being force-set in VCL.

  4. 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, immutable

    A growing Age on repeated requests confirms the edge is serving from cache. cf-cache-status: HIT (Cloudflare), X-Cache: Hit from cloudfront, or X-Cache: HIT (Fastly) confirm an edge hit.

  5. Confirm surrogate headers are stripped. The browser response must not contain Surrogate-Control or CDN-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.

Age consumes the freshness budget A time axis from zero to 3600 seconds shows the full s-maxage window, the portion already consumed as Age at the edge, and the small remainder left for the next cache downstream. Freshness is a budget, and Age spends it t = 0 · origin fill · Cache-Control: s-maxage=3600 total shared-cache freshness window granted by the origin Age climbs to 3500s while the object sits at the shield 100s of usable life left downstream 0s 1200s 2400s 3600s Downstream freshness is s-maxage minus Age, not s-maxage. A browser that receives this response caches it for 100 seconds, however generous the origin was.

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:

  1. Stop the bleeding at the origin. Change the origin header to a short or zero TTL (Cache-Control: public, max-age=0, s-maxage=0 or no-cache) so all future fills are short-lived.
  2. 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.
  3. Verify with Age. After purge, the first request should show Age: 0 and a MISS/EXPIRED status, then climb again.
  4. 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-Cookie on a response makes most CDNs treat it as private and skip the shared cache unless you explicitly strip the cookie at the edge.
  • s-maxage implies proxy-revalidate semantics on some caches — a stale shared object must revalidate, which can surprise you if the origin is down. Pair it with stale-if-error for resilience.
  • max-age=0 and no-cache are not equivalent: max-age=0 permits serving after a successful revalidation; no-cache requires revalidation every time but is otherwise similar. Neither prevents storage — only no-store does.
  • CloudFront ignores CDN-Cache-Control unless you add it to the origin response and rely on Cache-Control semantics instead; do not assume cross-CDN header parity.
  • immutable is 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-Control value 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.
  • Age is 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 301 with the default lifetime can pin a bad redirect at the edge and in every browser that saw it; use 302 with 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 HEAD differently from GET will confuse both your verification commands and the cache itself. Test with curl -sI and curl -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.

Back to CDN Caching & Performance Optimization