Back in 2018, a retail client called us in a panic on Black Friday. Their CDN was still serving last year's prices — $49 instead of $89 — for nearly four hours after they'd updated their product API. The problem wasn't the CDN itself, it was a single line: Cache-Control: max-age=86400 applied to everything. That one-size-fits-all freshness rule cost them thousands in lost margin and a lot of angry support tickets. If you've ever wondered why some of your content feels stuck or why users see different versions of the same page, you're running into content expiration issues, and getting your freshness policy right is how you fix it.
What "Freshness" Actually Means at the Edge
When we talk about CDN freshness, we're not talking about whether your file is new or old on your origin server. We're talking about whether the copy sitting on an edge node — that server in Chicago or Frankfurt closest to your user — is still considered valid to serve without checking back home. Every cached object has a clock attached to it, and that clock is driven by your TTL strategy. Once that clock runs out, the object is considered stale, and the next request has to go back to your origin to revalidate.
I've seen teams treat this like it's just a performance knob, but it's really a contract between your origin and the edge. You're telling the CDN, "You don't need to bother me for the next X seconds, I've got this." If X is too long, users get stale data. If it's too short, you're hammering your origin for no reason and your cache hit ratio tanks. I worked with a news site that set a 24-hour TTL on their homepage because they wanted to "reduce origin load." Their breaking news was 24 hours late for anyone hitting a warm edge. We didn't need a complex fix — we just needed to match the expiration policy to how often that homepage actually changed.
For beginners, the core controls are pretty simple and they all come from standard HTTP headers. The most important is Cache-Control with max-age, and related directives like s-maxage for shared caches like CDNs, and stale-while-revalidate which is a lifesaver. The HTTP Working Group Specifications define exactly how these directives should be interpreted, so you aren't guessing at vendor-specific behavior. Here's the basic pattern I still use as a starting template for most static-ish assets:
# Basic freshness headers for an image that rarely changes
HTTP/1.1 200 OK
Cache-Control: public, max-age=604800, s-maxage=2592000, stale-while-revalidate=86400
ETag: "a3f8c9e2b1d4"
Last-Modified: Tue, 14 Oct 2026 08:00:00 GMT
# That tells the browser: keep for 7 days (604800s)
# Tells the CDN: keep for 30 days (2592000s)
# If stale, serve stale for up to 1 day while fetching fresh in background
Aligning Your TTL Strategy With How Content Really Behaves
Your content doesn't all change at the same rate, so your CDN Content Lifecycle Management: From Upload to Expiration and Archival can't use one TTL for everything either. This is where a lot of teams go wrong — they pick a safe middle number like 1 hour and apply it everywhere. Think about your own site for a minute. Your logo and fonts? They probably haven't changed in months. Your product pricing or inventory count? That might change every few minutes. Your blog post from last year? That's essentially immutable unless you correct a typo.
What I do with new teams is have them bucket their content into three dynamics: static, semi-dynamic, and dynamic. Static is versioned assets — things with a hash in the filename like app.8f3a2c.js. You can give these massive TTLs because you'll never overwrite them; you'll just deploy a new file. Semi-dynamic is stuff that changes on a schedule or with human edits, like CMS pages, category listings, or hero banners. Dynamic is truly real-time: personalized API responses, cart data, live scores. Once you have those buckets, your freshness policy almost writes itself. You'll want to pair this thinking with a solid approach to Content Versioning Patterns for CDN: Atomic Deploys and Rollback-Ready Assets so your long TTLs don't paint you into a corner.
For that middle bucket — the semi-dynamic content — you don't want to choose between "always fresh" and "always cached." You can have both with revalidation. Instead of a long max-age, you use a short freshness window plus a validator. The web.dev Articles on HTTP caching explain this well: a short max-age with ETag or Last-Modified lets the CDN ask "has this changed?" with a tiny 304 response instead of downloading the whole file. It's much cheaper than a full origin fetch.
# Example Nginx / origin config for semi-dynamic content (e.g., /api/products)
location /api/products {
# Browser caches 60s, CDN caches 300s, serve stale for 30s while updating
add_header Cache-Control "public, max-age=60, s-maxage=300, stale-while-revalidate=30";
add_header Vary "Accept-Encoding";
etag on;
}
# Example for immutable versioned assets (e.g., /static/app.abc123.css)
location ~* \.[0-9a-f]{6,}\.(css|js)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
expires 1y;
}
Building a Freshness Policy You Don't Have to Babysit
A good content expiration plan shouldn't require you to manually purge the cache every time marketing updates a banner. If you're purging more than a few times a week, your TTL strategy is too aggressive. I tell beginners to start with defaults by path or content type, then only override when you have a reason. That means your CDN config or origin should automatically emit the right Cache-Control based on a CDN Content Taxonomy: Organizing Assets for Efficient Edge Delivery and Invalidation you've already mapped out — like /static/*, /images/*, /api/*. It's far more reliable than remembering to set headers in app code every time.
You should also measure whether your policy is working. Don't just set it and hope. Two numbers tell you almost everything: origin request rate and cache hit ratio. If your hit ratio for product images is 92% with a 7-day TTL, you're in great shape. If your HTML hit ratio is 15% with a 0-second TTL, you're not really using the CDN at all. According to the HTTP Working Group Specifications, a response without explicit freshness info can still be heuristically cached by some CDNs, which leads to really confusing bugs. Be explicit — always send a Cache-Control.
Here's the starter matrix I use with nearly every small-to-medium site I onboard. It's not gospel, but it's a solid default you can adjust after a week of watching metrics. Start conservative and extend TTLs as you get confident in your invalidation process.
| Content Type | Example Path | Recommended CDN TTL (s-maxage) | Browser TTL (max-age) | Expected Hit Ratio |
|---|---|---|---|---|
| Versioned Static Assets | /static/app.9c2a1b.js | 1 year (31536000s) + immutable | 1 year (31536000s) | 98-99% |
| Images & Media | /images/product/*.jpg | 30 days (2592000s) | 7 days (604800s) | 90-95% |
| CMS Pages / Blog | /blog/*, /about | 1 hour (3600s) | 5 min (300s) | 75-85% |
| Product API / Listing | /api/products, /category/* | 5 min (300s) | 60s | 60-70% |
| Personalized / Cart | /api/me, /cart | 0s (no-cache) or private | 0s | 0-5% (bypass) |
Translating Content Dynamics Into Cache-Control That Actually Works
Here's where most teams trip up — they've got a solid TTL strategy on paper, but the headers hitting the edge don't match it. You can't just set a blanket Cache-Control: max-age=3600 and call it a day. You've got to split browser cache from edge cache, and you've got to be explicit. For browsers, max-age is king, but for CDNs, it's s-maxage and CDN-Cache-Control that matter. If you don't set them, your edge will just fall back to max-age, and you'll end up either hammering your origin or serving stale content to users who shouldn't see it.
We typically tier it like this: immutable, versioned assets (think app.a3f8c9.js) get a year. HTML documents that reference them get 30-60 seconds at the edge with a generous stale-while-revalidate. API responses that change every few minutes? 10 seconds max-age, 30 seconds s-maxage, and a 60-second SWR window. That gives you a 92-96% edge hit ratio without making editors wait 15 minutes to see their changes. And if you're pulling from multiple backends, you need that consistency at the origin level — our guide on CDN Content Federation: Aggregating Content from Multiple Origins into Unified Delivery breaks down how to normalize those headers when you've got three different CMSs and an API gateway all feeding one CDN.
Don't trust your config until you've curled it. We still run a simple loop after every deploy: hit the edge, check age, cache-status, and cache-control, then purge a test surrogate key and verify it actually revalidates. If you're not seeing HIT on the second request within your TTL window, something's overriding your headers — often an origin framework adding no-cache or a CDN rule you forgot about.
# Example origin header strategy by content class
# 1. Immutable assets (hashed filenames) - cache forever at edge + browser
location ~* \.(css|js|woff2|jpg|webp)$ {
# Far-future for both browser and CDN
add_header Cache-Control "public, max-age=31536000, immutable";
add_header CDN-Cache-Control "public, s-maxage=31536000, immutable";
}
# 2. HTML documents - short edge TTL, serve stale while revalidating
location ~* \.html$ {
add_header Cache-Control "public, max-age=0, must-revalidate";
add_header CDN-Cache-Control "public, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400";
add_header Surrogate-Key "html template";
}
# 3. Dynamic API - ultra-short TTL with background revalidation
location /api/ {
add_header Cache-Control "public, max-age=10, s-maxage=30, stale-while-revalidate=60, stale-if-error=300";
add_header Surrogate-Key "api product-data";
add_header Vary "Accept, Accept-Encoding";
}
Advanced Patterns and When Freshness Breaks
Once you've got the basics dialed in, the interesting problems start. stale-while-revalidate is fantastic for hiding origin latency — we'll serve a 45-second-old response instantly while we fetch a fresh copy in the background, so users never see that 600ms origin fetch. But pair it wrong and you'll hide bugs for hours. If your origin returns a 500 during revalidation, that stale copy stays up thanks to stale-if-error=86400, which is great for resilience but terrible if you’re trying to debug why your new deployment isn’t showing. Always log revalidation failures separately; don’t let SWR silently paper over a broken origin.
The other nightmare is the thundering herd. Say you set a 60-second TTL on a popular product page and you’ve got 2,500 RPS hitting it globally. At T+61 seconds, every edge node decides it’s stale at once and slams your origin with 40+ concurrent revalidations. We fix that with two things: cache locking (or request coalescing — only one request goes to origin, the rest wait 2-3 seconds) and jittered TTLs. Add 5-10% randomization to your s-maxage so not every node expires at the exact same millisecond. If you're running a CDN Content Replication and Consistency Models: Eventual, Strong, and Causal setup with tiered shielding, that coalescing at the shield layer cuts origin load by 80-90% during expiration spikes.
And then there's purging. Instant purge by surrogate key (like Surrogate-Key: product-12345) beats URL purges every time when one product update touches 12 pages, 3 API endpoints, and an image variant. But purging across regions isn't instant everywhere — it’s eventually consistent by design. If you're seeing a 4-8 second propagation delay between US-East and APAC edges, that's normal. If you're seeing 2+ minutes, check your Multi-Region Content Synchronization: Keeping CDN Edges in Agreement setup. We’ll often use versioned keys or a short 10-second TTL instead of relying on purge for inventory or pricing where even 5 seconds of cross-region drift costs money. For everything else, key-based purge plus a 30-second SWR gives you the best of both worlds.
Frequently Asked Questions
What’s a safe default TTL if I’ve no idea how often a content type changes?
Start conservative: 60 seconds s-maxage with stale-while-revalidate=300 for HTML/API, and 7 days for unversioned images. Monitor your origin hit rate for a week. If you're sitting above 85% HIT at the edge and editors aren't complaining about stale content, you can slowly stretch it. It's way easier to increase a TTL than to explain why customers saw yesterday's price for an hour.
Should I use cache purging or just set a short TTL for frequently updated content?
If content updates unpredictably and freshness matters within seconds — like breaking news, cart pricing, or live scores — use purging by surrogate key. A 5-10 second TTL will still serve stale data for that window and will hammer your origin. If updates are predictable and a 30-60 second delay is acceptable, a short TTL with SWR is simpler and avoids purge-rate limits (most CDNs throttle you to 500-1000 purges/min).
How do I cache personalized content without leaking one user’s data to another?
Don't cache HTML that contains PII at the edge unless you're using Vary: Cookie or EdgeWorkers to create a per-user key — and even then, your hit ratio will tank to <5%. Better pattern: cache the shared shell (layout, product info) for 60-300 seconds, and fetch personalization via a separate, uncached API call (Cache-Control: private, no-store) or an edge-computed include. You'll keep a 90%+ HIT rate on the heavy assets and keep private data private.
Why am I still seeing stale content after a successful purge?
Three culprits, in order: 1) Browser cache — you purged the edge, but the user's browser still holds max-age=3600, so they won't revalidate for an hour. Fix it with versioned URLs. 2) Tiered cache — you purged the edge but not the shield/second-tier cache; make sure your purge propagates to the shield layer. 3) Negative caching — your CDN cached a 404 or 500 for 120 seconds and your purge key didn't cover error objects. Always include error-specific surrogate keys or set a short negative TTL like 5-10 seconds.