CDN Caching Strategies: TTL, Cache-Control Headers and Invalidation Patterns

Getting CDN caching right is not about flipping a single switch. It is about making three decisions work together consistently: how long you cache something, how you tell every layer to honor that decision, and how you undo it when you are wrong. I have seen teams spend months optimizing image compression and edge logic, only to leave 40% of their origin traffic on the table because their TTLs were too short or their Cache-Control headers contradicted each other. If you are new to edge delivery, this article will give you a practical mental model for TTL, Cache-Control, and invalidation that you can apply on day one, with configuration you can actually ship.

Why TTL Is the Most Expensive Line in Your CDN Config

In my experience, TTL — time to live — is where most cache performance is won or lost. TTL tells the CDN and the browser how many seconds an object is considered fresh and can be served without revalidation. Set it too short and you pay for origin fetches, egress, and added latency on every request. Set it too long and you serve stale content until you manually purge.

A common misconception for beginners is that a CDN “caches everything automatically.” It does not. The edge makes a caching decision on every response based on the headers your origin sends and the rules you configure in the CDN control plane. If neither provides a TTL, many CDNs will default to not caching, or to a very short heuristic TTL. That default will silently keep your cache hit ratio low.

I typically separate TTL into three distinct scopes, because they answer different questions:

The Three TTL Scopes You Must Configure

1. Browser TTL (client cache): Controlled by Cache-Control: max-age. This lives in the user's browser and avoids a network round-trip entirely. Great for performance, but you have zero ability to purge it. Once you send a browser a 1-year max-age, that browser will not ask anyone again until it expires.

2. Edge TTL (CDN cache): Controlled by s-maxage, CDN-Cache-Control, or a CDN override rule. This is your primary lever for offload. It determines how long a PoP keeps the object before it must revalidate with origin or a parent cache.

3. Origin Shield / Tiered TTL: If you use a shield PoP or multi-tier cache, there is often a longer TTL at the shield than at the edge. This protects your origin from thundering herds when many edges expire at once. I have found that a shield TTL 2x to 5x the edge TTL dramatically smooths origin load after a purge or traffic spike, especially if you are operating at scale. You can read more about that architecture in Origin Shield and Multi-Tier Caching: Reducing Origin Load at Scale.

Think of it this way: browser TTL optimizes for the individual user, edge TTL optimizes for your global audience, and shield TTL optimizes for your origin's survival. You need to set all three intentionally.

How Cache-Control Directives Actually Behave at the Edge

Cache-Control is the HTTP header that carries your caching policy. The syntax looks simple, but interactions between directives confuse even experienced teams. The HTTP Working Group Specifications (RFC 9111) is the authoritative reference here, and I recommend reading sections 5.2 and 4.2.1 if you want to understand exactly how freshness and validation are supposed to work.

Here are the directives that matter most for CDN work:

max-age=seconds: Freshness for both browsers and shared caches (unless s-maxage overrides it at the CDN). max-age=300 means fresh for 5 minutes.

s-maxage=seconds: Freshness specifically for shared caches like CDNs. It overrides max-age at the edge only, not in the browser. This is how you can tell browsers to cache for 1 minute but edges to cache for 1 hour.

CDN-Cache-Control / Cloudflare-CDN-Cache-Control / Surrogate-Control: Vendor-specific headers that override Cache-Control only at the CDN. CDN-Cache-Control is now standardized and preferred because it avoids leaking your edge policy to browsers. If your CDN supports it, use it. It prevents mistakes where a browser caches something you only meant to cache at the edge.

no-store and no-cache: These are frequently misused. no-store means do not store the response anywhere. no-cache does not mean “do not cache” — it means “you may store it, but you must revalidate with the origin before every reuse.” For private, user-specific data, you need private, no-store or private, max-age=0, must-revalidate.

must-revalidate and proxy-revalidate: Once a response becomes stale, these directives force revalidation before reuse. Without them, some implementations may serve stale if the origin is unreachable. With them, they must fail or revalidate.

stale-while-revalidate and stale-if-error: These are performance lifesavers. stale-while-revalidate=60 tells the cache it can serve a stale response for 60 seconds while it fetches a fresh version in the background. This hides origin latency and eliminates the cache miss penalty for the user who triggered the revalidation. I use this on nearly every cacheable asset where slight staleness is acceptable. For a full implementation walkthrough, see Stale-While-Revalidate: The CDN Pattern That Eliminates Cache Misses.

# Example 1: Split browser and edge TTLs correctly
# Browser caches for 60s, edge caches for 1 hour,
# edge can serve stale for 30s while revalidating
HTTP/1.1 200 OK
Cache-Control: max-age=60, s-maxage=3600, stale-while-revalidate=30
CDN-Cache-Control: max-age=3600, stale-while-revalidate=30
Content-Type: image/webp
Surrogate-Key: product-image prod-12345
# Example 2: Private, non-cacheable API response
# Do not store in edge or browser caches
HTTP/1.1 200 OK
Cache-Control: private, no-store, max-age=0
Pragma: no-cache
Content-Type: application/json

One pattern I have seen fail repeatedly is sending contradictory headers. For example, Cache-Control: max-age=86400, no-cache is ambiguous and interpreted differently by different CDNs and browsers. Pick one intent per response: either cacheable with a clear freshness lifetime, or explicitly not cacheable. If you need to force revalidation every time but still allow caching, use max-age=0, must-revalidate with an ETag or Last-Modified validator. That way the edge can send a lightweight If-None-Match conditional request and get a 304 Not Modified instead of a full origin fetch.

Tuning TTLs by Content Archetype: Static, Dynamic, and Personalized Assets

There is no single ideal TTL. The right value depends on how often the content changes and how damaging it is to serve a stale version. I group content into four archetypes and assign a TTL policy to each.

Immutable static assets: Versioned JS, CSS, images, fonts, and other files that include a content hash in the filename (e.g., app.a3f1b9.css). These never change in place — a deploy creates a new filename. They can be cached aggressively: 1 year at both browser and edge. This is the easiest win.

Mutable static assets: Unversioned images, PDFs, or assets where the URL stays the same when the file changes. Cache for minutes to hours, and rely on purge or surrogate keys when you update them. Do not give these a 1-year browser TTL unless you have a cache-busting strategy.

Semi-dynamic pages: Blog posts, product pages, category listings, or marketing pages that change occasionally. These benefit from short edge TTLs (60-300 seconds) combined with stale-while-revalidate. Users almost always get a cache hit, and updates propagate within minutes.

Personalized or dynamic responses: Shopping carts, account pages, authenticated API responses. Default to private, no-store unless you can safely key the cache by user or token. Caching these incorrectly creates privacy incidents.

The table below summarizes a baseline I have used successfully as a starting point for new properties. Adjust based on your change frequency and tolerance for staleness.

Content Archetype Browser TTL (max-age) Edge TTL (s-maxage / CDN-Cache-Control) Stale-While-Revalidate Invalidation Method
Versioned / Hashed Assets (immutable) 31536000 (1 year, immutable) 31536000 (1 year) 86400 (1 day) None needed — new URL on deploy
Unversioned Images / PDFs 3600 (1 hour) 86400 (1 day) 3600 (1 hour) Purge by URL or Surrogate-Key
Semi-Dynamic HTML / JSON 60 300 30-60 Surrogate-Key / Tag purge
Personalized / Authenticated API 0 (private, no-store) 0 (do not cache) 0 No cache; use ETag revalidation if needed

Notice how the browser TTL is almost always shorter than or equal to the edge TTL, except for immutable assets. This is deliberate. You can purge the edge in seconds via API, but you cannot purge a user's browser cache. If you set a 24-hour browser max-age on a product price and then lower the price, users who visited yesterday will see the old price until their browser cache expires, regardless of what you do at the CDN.

# Example 3: Nginx origin config for archetype-based headers
# Immutable hashed assets
location ~* \.[a-f0-9]{8}\.(js|css|webp|woff2)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
  add_header CDN-Cache-Control "public, max-age=31536000";
  add_header Surrogate-Key "static immutable";
}

# Semi-dynamic HTML
location = /product/ {
  add_header Cache-Control "public, max-age=60, s-maxage=300, stale-while-revalidate=30";
  add_header CDN-Cache-Control "public, max-age=300, stale-while-revalidate=30";
  add_header Surrogate-Key "product html";
}

If you are seeing a low hit ratio after setting reasonable TTLs, the issue is often cache fragmentation from query strings, cookies, or Vary headers. That problem is covered in detail in CDN Cache Hit Ratio Optimization: From 60% to 95% Hit Rate.

Cache Invalidation Trade-Offs: Purge-by-URL, Surrogate Keys, and Wildcards

Phil Karlton’s quote about cache invalidation holds up at the edge. A long TTL without a clean purge strategy leaves you stuck choosing between serving stale content and invalidating too much. In my experience, teams should decide on their invalidation pattern before they decide on their TTL, not after.

There are three practical patterns you will encounter:

1. Purge-by-URL (Exact URL Purge)

You invalidate a specific path, e.g., /products/123. This is simple and supported everywhere. It works well when you have a small number of URLs to clear and you can enumerate them. It breaks down when one content change affects hundreds or thousands of URLs — for example, updating a price that appears on a product page, category pages, search results, and recommendation widgets.

2. Surrogate Keys / Cache Tags (Tag-Based Invalidation)

This is the pattern I recommend for most applications. Your origin adds a Surrogate-Key (Fastly) or Cache-Tag (Cloudflare, Akamai) header listing one or more tags for that object. Example: a product page might be tagged prod-123, category-shoes, brand-nike. To invalidate, you send one API call: purge by tag prod-123. Every cached object with that tag is removed or marked stale across all PoPs, regardless of URL. One update, one purge call, full coverage.

Surrogate keys solve two problems at once: you do not need to know every URL that references a piece of data, and you avoid the blast radius of a wildcard purge. The trade-off is operational discipline. You must actually emit accurate tags from your origin, keep the key cardinality within vendor limits (e.g., 128-512 keys per object, 16KB header limit), and monitor purge propagation time.

3. Wildcard and Global Purges

/products/* or a full site purge (purge all) is the emergency brake. It is immediate and absolute, but it evicts everything matched, which collapses your cache hit ratio and floods your origin. I have measured origin RPS spikes of 15x to 30x immediately after a global purge on high-traffic sites. Reserve this for incidents, key rotations, or mass content rewrites. If you find yourself wildcard purging regularly, that is a sign you need to move to surrogate keys. The different purge behaviors and their performance impact are compared thoroughly in CDN Purge Strategies: Selective Invalidation, Tag-Based and Surrogate Keys.

Practical advice on implementation: add surrogate keys at your application layer where you have the most context about data dependencies, not in a downstream proxy that has to guess. A product API endpoint knows which product IDs contributed to its response; a Varnish layer in front of it does not. Also, prefer soft purges when your CDN offers them. A soft purge marks content as stale rather than deleting it immediately, so the next request can be served stale while revalidating, avoiding a hard miss queue.

Diagnosing Cache Behavior With Age, X-Cache, and Response Validators

Once your policies are deployed, you need to verify they actually work. Relying on the vendor dashboard alone is not enough, because dashboards aggregate and delay. Inspect live response headers.

Headers That Tell You What Happened

Cache-Control and CDN-Cache-Control: Confirm the origin is sending what you intended, and that the CDN is not overriding it with a conflicting rule. Watch for duplicate Cache-Control headers from different layers; the last one may win depending on the implementation.

Age: Seconds the object has been in cache. If you set s-maxage=3600 and you repeatedly see Age: 5 or no Age header at all, the object is not caching. Look for Set-Cookie, Authorization, or Vary: * leaking into cacheable responses, or a CDN rule forcing bypass-cache.

X-Cache / Cf-Cache-Status / Age combinations: HIT, MISS, HIT-STALE, REVALIDATED, BYPASS, EXPIRED tell you the decision at each tier. A MISS with Age: 0 is expected on first fetch; a persistent MISS on repeated requests to the same PoP means your key is fragmented or non-cacheable.

ETag and Last-Modified: Validators used for conditional revalidation. With ETag: "abc123", the edge can send If-None-Match: "abc123" and receive 304 Not Modified without downloading the body. This saves origin bandwidth even when TTLs are short.

According to guidance from web.dev Articles on HTTP caching, using strong validators with a short freshness window combined with stale-while-revalidate often outperforms simply setting a long max-age, because it gives you both freshness and resilience to origin slowness.

A Quick Manual Test Loop

When I audit a new property, I run this sequence from a single geographic test point to avoid PoP differences:

1. curl -i -H "Cache-Control: no-cache" https://example.com/products/123 to force a fresh origin fetch and prime the cache. Note the Age and Cache-Control returned.
2. curl -i https://example.com/products/123 immediately again — I expect X-Cache: HIT and Age increasing.
3. Wait past the browser max-age but before edge s-maxage, request with a browser-like header set — confirm the browser would revalidate but the edge still serves HIT.
4. Update the underlying data and fire your surrogate-key purge, then repeat the request — you should see MISS or STALE once, then HIT again.

If you see BYPASS unexpectedly, check for CDN rules that bypass cache on cookie presence — many default configurations bypass when Cookie is present. For API and HTML endpoints that do not depend on cookies, explicitly strip or ignore those cookies at the edge for cache key purposes, while still forwarding them to origin if needed for personalization logic that runs after the cache lookup.

Frequently Asked Questions

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

max-age applies to both browsers and shared caches like CDNs. s-maxage applies only to shared caches and overrides max-age at the edge. Use s-maxage or CDN-Cache-Control when you want edges to cache longer than browsers, which is the recommended pattern for most HTML and API responses where you want fast edge hits but the ability to purge quickly without waiting for browser caches to expire.

How long should my CDN TTL be for HTML pages?

For semi-dynamic HTML, I typically start with max-age=60 in browsers and s-maxage=300 at the edge, plus stale-while-revalidate=30. This gives you a 5-minute freshness window globally with a high hit ratio, and updates propagate quickly after a surrogate-key purge. Static HTML that rarely changes can use longer TTLs, while personalized HTML should not be cached at all. Measure your origin change rate and adjust from there.

Is purging the CDN bad for performance?

Purging itself is fast — most vendors propagate a purge globally in 1-3 seconds — but the performance cost comes after the purge. Every purged object causes a cache miss on next request, which increases origin load and latency until the cache refills. That is why selective purging with surrogate keys is preferable to wildcard or full purges; it limits the blast radius to only the objects that actually changed and keeps your hit ratio stable.

Can I cache API responses behind a CDN?

Yes, if the responses are public or can be partitioned by a cache key that includes relevant request attributes. Use CDN-Cache-Control with a short TTL, add Vary or custom cache key rules for things like Accept-Language, and include surrogate keys for invalidation. Do not cache responses that contain private user data, and always send private, no-store when a response is authenticated unless you have explicitly designed a per-user cache key.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker