CDN Cache-Control Headers Explained

CDNs decide what to cache, for how long, and how to revalidate based almost entirely on HTTP response headers your origin sends. Get the headers right and your CDN does the right thing automatically — long edge cache, short browser cache, graceful staleness, fast purges. Get them wrong and you serve stale content, leak personal data via shared cache, or get zero cache hit rate. This guide walks every header that matters, what each directive actually does, and the canonical patterns for common content types.

Cache-Control: the master header

Cache-Control replaced the older Expires and Pragma headers in HTTP/1.1 and remains the central control. It is a comma-separated list of directives:

Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400

The directives describe how caches should treat the response. Each cache (browser, CDN, intermediate proxy) reads the same header and applies the rules. The most important directives:

public / private

  • public — explicitly safe to cache in shared caches (CDNs, proxies). The default if max-age is set and the response is not personalized.
  • private — only browser caches may store this. Shared caches must not. Use for any response with user-specific content.

Without an explicit public or private, the cacheability depends on other directives and the CDN's defaults. For shared-cache responses, explicitly say public.

max-age=N

Cache for N seconds. After that, the cached copy is stale and must be revalidated (or refetched) before being served.

  • max-age=0 — must revalidate every time. Effectively no cache, but with revalidation rather than full refetch.
  • max-age=60 — short TTL for frequently-updated content like API responses.
  • max-age=3600 — 1 hour, common for HTML pages.
  • max-age=31536000 — 1 year, used for immutable content with hashed URLs (CSS/JS bundles with content hashes).

s-maxage=N

Same as max-age, but for shared caches only (CDNs). Overrides max-age in shared caches. The pattern:

Cache-Control: public, max-age=60, s-maxage=3600

"Browser caches for 60 seconds; CDN caches for 1 hour." This is the canonical pattern for HTML that should update on browser reload but stay cached at the edge for performance.

no-cache vs no-store

The most misunderstood pair of directives.

  • no-cache — must revalidate with origin before each use. Caches MAY store the response but must check with origin before serving it. A conditional request goes upstream every time.
  • no-store — do not store the response anywhere. The truly "don't cache" directive.

Use no-store for genuinely private data (banking pages, personal medical info). Use no-cache for dynamic pages that benefit from ETag-based revalidation. Use neither for anything cacheable.

must-revalidate vs proxy-revalidate

  • must-revalidate — once stale, the cache MUST revalidate; it cannot serve stale even if origin is unreachable.
  • proxy-revalidate — same as must-revalidate but applies only to shared caches, not browsers.

stale-while-revalidate=N

RFC 5861. After the response goes stale (past max-age), serve stale content while asynchronously fetching a fresh copy in the background, for up to N seconds. Excellent UX trade-off: users always get a fast response; the CDN refreshes content opportunistically.

Cache-Control: max-age=60, stale-while-revalidate=86400

"Fresh for 60 seconds. Stale-but-served for up to 24 hours after that, with a background revalidation per request." Users never wait for origin during the stale window. Originally a non-standard extension, now broadly supported.

stale-if-error=N

Similar to stale-while-revalidate but specifically for when origin returns an error. Serve stale for up to N seconds if origin returns 5xx. Provides graceful degradation during origin outages.

immutable

Tells the browser "this response will never change as long as the URL is the same." Browsers may skip revalidation entirely. Use for content with content-addressed URLs (CSS/JS bundles named with their hash):

Cache-Control: public, max-age=31536000, immutable

The Vary header: cache fragmentation

If your response varies based on a request header, the cache needs to know — otherwise it might serve the wrong variant. Vary lists which request headers should be part of the cache key:

Vary: Accept-Encoding
Vary: Accept-Encoding, Accept-Language
Vary: Accept-Encoding, User-Agent

Each Vary header value multiplies cache fragmentation:

  • Vary: Accept-Encoding — usually 2-3 variants (gzip, br, identity). Manageable.
  • Vary: Accept-Language — N variants for N languages. Manageable if you have a small set.
  • Vary: User-Agent — thousands of variants because UA strings differ by minor version. Almost always wrong — fragments the cache to nearly zero hit rate.
  • Vary: Cookie — usually unique per user. Effectively disables shared caching.

Use Vary minimally and precisely. If you only care about gzip, use Vary: Accept-Encoding alone — do not include other headers "just in case."

ETag and revalidation

ETag is an opaque identifier the origin assigns to a specific version of a resource. When the cache wants to revalidate, it sends a conditional request:

GET /image.jpg HTTP/1.1
If-None-Match: "abc123"

If the current ETag still matches, origin responds:

HTTP/1.1 304 Not Modified

No body. The cache extends the TTL on its existing copy. If the ETag has changed:

HTTP/1.1 200 OK
ETag: "def456"
... (full body)

ETags are the efficient revalidation mechanism — they save bandwidth on unchanged content. They are particularly useful for large responses where the cost of a full re-download is significant.

ETag generation:

  • Strong ETags: byte-for-byte identical bodies share an ETag. Web servers often generate from file hash.
  • Weak ETags (prefixed with W/): semantically equivalent but possibly different bytes. Used when minor differences (formatting, timestamps in HTML) don't matter for caching.

Last-Modified and If-Modified-Since

The older alternative to ETag. Origin sends:

Last-Modified: Sat, 25 May 2026 09:14:21 GMT

Cache revalidates with:

If-Modified-Since: Sat, 25 May 2026 09:14:21 GMT

Returns 304 if unchanged. Less precise than ETag (resolution is 1 second; useless for sub-second updates) but cheap to generate from file mtime.

If both ETag and Last-Modified are present, caches prefer ETag.

Surrogate-Control: separate directives for CDNs

Surrogate-Control is an Edge Architecture-specific header (originally from Akamai) that lets you tell the CDN something different from what you tell browsers:

Cache-Control: max-age=0
Surrogate-Control: max-age=3600

"Browsers do not cache; CDN caches for 1 hour." Functionally similar to s-maxage but more explicit. Supported by Akamai, Fastly, and some others; not universal. s-maxage in Cache-Control is the more portable equivalent.

CDN-Cache-Control: the new standard

RFC 9213 (2022) introduced CDN-Cache-Control as the cross-vendor replacement for Surrogate-Control. CDNs that support it use CDN-Cache-Control for edge directives and pass Cache-Control through to browsers unchanged:

Cache-Control: max-age=60
CDN-Cache-Control: max-age=3600

Cloudflare, Fastly, and Vercel support CDN-Cache-Control. AWS CloudFront added partial support in 2024. For new applications, prefer CDN-Cache-Control over Surrogate-Control or s-maxage when the CDN supports it.

Canonical patterns by content type

Versioned/immutable assets (CSS, JS bundles)

Cache-Control: public, max-age=31536000, immutable

1-year cache, no revalidation needed. Filenames include content hash; new versions get new URLs.

HTML pages

Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400
Vary: Accept-Encoding

Browsers see updates on reload (1 minute); CDN caches for an hour; graceful staleness for 24 hours during origin issues.

API responses (public data)

Cache-Control: public, max-age=30, stale-while-revalidate=300
ETag: "abc123"
Vary: Accept-Encoding

Short TTL; revalidation possible via ETag; graceful staleness during deploys.

API responses (authenticated user data)

Cache-Control: private, max-age=60
Vary: Cookie

Browser caches briefly. CDN never caches (Vary: Cookie + private).

Static media (images, video)

Cache-Control: public, max-age=86400
ETag: "filehash"

One-day cache; revalidate via ETag for updates.

Sensitive data (banking, medical)

Cache-Control: no-store, no-cache, must-revalidate, max-age=0
Pragma: no-cache
Expires: 0

Belt-and-suspenders. The Pragma and Expires lines are legacy fallbacks for HTTP/1.0 caches.

Debugging cache decisions

Every major CDN exposes cache status via a response header:

  • Cloudflare: cf-cache-status: HIT / MISS / EXPIRED / BYPASS / DYNAMIC
  • CloudFront: x-cache: Hit from cloudfront / Miss from cloudfront / RefreshHit from cloudfront
  • Fastly: x-cache: HIT, MISS and x-cache-hits: N
  • Akamai: x-cache: TCP_HIT / TCP_MISS / TCP_REFRESH_HIT
  • Vercel: x-vercel-cache: HIT / MISS / STALE / BYPASS

When debugging "the CDN isn't caching this," request the URL with curl -I and inspect:

  1. The CDN's cache-status header (above).
  2. The Cache-Control header — what your origin is saying.
  3. Whether Set-Cookie is in the response (often disables caching by default).
  4. Whether Authorization or Cookie is in the request (often bypasses cache by default).
  5. The CDN's dashboard rules — explicit overrides for the URL pattern.

Frequently Asked Questions

What is the difference between max-age and s-maxage?

max-age sets the cache TTL for all caches; s-maxage sets the TTL specifically for shared caches (CDNs, proxies) and overrides max-age for them. The pattern is: Cache-Control: public, max-age=60, s-maxage=3600. Browsers cache for 60 seconds; CDNs cache for 1 hour. This lets you have long edge caching for performance while keeping browser cache short so users see updates on reload without manual purging.

What does no-cache actually do?

no-cache does NOT mean "do not cache." It means caches must revalidate with origin before serving — via a conditional GET using If-None-Match or If-Modified-Since. If origin returns 304 Not Modified, the cached copy is served; if 200, the new copy replaces it. To truly prevent caching, use no-store. The confusing naming (no-cache means revalidate, no-store means don't cache) trips up everyone the first time.

When should I use the Vary header?

Use Vary when the response depends on a request header. The most common case is Vary: Accept-Encoding (different responses for gzip vs Brotli vs identity) — almost universally set automatically. Vary: Accept-Language for localized content. Vary: User-Agent is dangerous because user agent strings vary wildly, fragmenting the cache. Vary: Cookie typically prevents caching entirely. Use Vary sparingly; each Vary header value multiplies the cache fragmentation.

What is stale-while-revalidate?

stale-while-revalidate tells the cache it may serve stale content for a specified duration after expiration while asynchronously fetching a fresh copy in the background. Example: Cache-Control: max-age=60, stale-while-revalidate=86400 means "fresh for 60 seconds, but you may serve stale for up to 24 hours after that while you fetch a new version." This combines fast responses with eventually-correct content — users never wait for origin during the stale window.

Why does my CDN ignore my Cache-Control header?

Several reasons: (1) the CDN's dashboard overrides may set explicit TTLs that take precedence; (2) the request includes a Cookie or Authorization header that defaults to bypassing cache; (3) the response includes Set-Cookie, which most CDNs treat as uncacheable by default; (4) the URL contains query parameters not in the CDN's cache key. Check the CDN's debugging tools (Cloudflare's cf-cache-status header, CloudFront's x-cache header) for the actual cache decision and reason.

Related Guides

More From This Section