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.

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:

  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.

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

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.

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.

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.

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.

Back to CDN Caching & Performance Optimization