Cloud & DevOps1. August 202610 min read

CDN Caching: Serving the World From the Edge

Your origin should be bored. Here is how to design cache keys, TTLs, and purge pipelines so the edge does the work and your servers barely notice traffic.

Von Innovation T Team


Your origin server should be bored. If it is answering the same request ten thousand times an hour, you are paying compute prices to do a cache's job. CDN caching done right serves most of your traffic from a machine sitting within a few dozen milliseconds of the user, and the difference shows up in revenue and infrastructure bills, not just dashboards.

Why the edge wins: it is physics, not marketing

A request from Tunis to a server in Frankfurt crosses roughly 2,000 kilometers of fiber. Add TCP and TLS handshakes and you have burned multiple round trips before a single byte of your response moves. From São Paulo or Jakarta to that same origin, each round trip can cost hundreds of milliseconds. No amount of backend optimization recovers time lost to distance.

A CDN puts hundreds of points of presence (PoPs) between your origin and your users. When content is cached at the PoP, the round trip collapses to the distance between the user and the nearest edge node. In our experience, moving cacheable HTML and assets to the edge typically cuts time to first byte by 3x to 10x for far-away users, and that lands directly on Largest Contentful Paint. If you are fighting Core Web Vitals, this is usually the highest-leverage single move, and we cover the measurement side in our Core Web Vitals field guide.

The second win is offload. Every request served from the edge is a request your origin never sees. That means smaller instance sizes, fewer autoscaling events, less database pressure, and a much calmer on-call rotation during traffic spikes.

The cache key is everything

A CDN cache is a giant hash map. The cache key is the hash map key. Get it wrong in one direction and you fragment the cache into millions of useless entries. Get it wrong in the other direction and you serve one user's private data to another.

The default key is usually scheme + host + path + query string. Almost every real system needs to modify that default:

  • Strip marketing parameters. utm_source, fbclid, gclid and friends create a distinct cache entry per campaign click. Normalize them out of the key or your hit ratio dies quietly.
  • Sort query parameters. ?a=1&b=2 and ?b=2&a=1 are the same resource. Sort before hashing.
  • Whitelist, do not blacklist. Only parameters your application actually reads should enter the key. Everything else is an attack surface and a fragmentation source.
  • Add dimensions deliberately. If you serve different HTML per country or per device class, add exactly that dimension (a normalized country code, a boolean is_mobile), never the raw header.

On Fastly, key normalization looks like this in VCL:

sub vcl_recv {
  set req.url = querystring.regfilter(req.url, "^(utm_|fbclid|gclid)");
  set req.url = querystring.sort(req.url);
}

Cloudflare exposes the same idea through Cache Rules and custom cache keys. The tool differs, the principle does not: the key must contain everything that changes the response, and nothing else.

Vary is a footgun

The Vary response header tells caches which request headers change the response. Vary: Accept-Encoding is fine and standard. Vary: User-Agent splits your cache across thousands of browser strings and effectively disables it. Vary: Cookie is worse: nearly every request carries unique cookies, so nothing is ever shared. If you need per-segment responses, normalize the input at the edge into a small custom header (three or four possible values) and vary on that instead.

The header hierarchy: who is allowed to cache what

Cache-Control is a contract with two audiences: browsers and shared caches. Use the directives that separate them.

# Hashed static assets: cache forever, everywhere
Cache-Control: public, max-age=31536000, immutable

# HTML pages: browser always revalidates, CDN holds it
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=3600

# Authenticated pages: browser only, never shared caches
Cache-Control: private, no-store

The mechanics that matter:

  • s-maxage overrides max-age for shared caches. This lets you keep browsers on a short leash while the CDN holds content longer, which matters because you can purge a CDN but you cannot purge a user's browser.
  • no-cache does not mean "do not cache." It means "cache, but revalidate before serving." no-store is the directive that actually forbids storage.
  • immutable plus content hashing is the endgame for assets. If app.3f9a2c.js changes, its name changes. A one-year TTL is safe because the URL is the version.

Decide TTLs by mutability class, not by gut feeling. Hashed assets: one year. Product images: days. Rendered HTML: minutes at the CDN, zero in the browser. API responses: seconds, which is still transformative under load.

Serve stale on purpose

The most underused directives in the spec are stale-while-revalidate and stale-if-error.

stale-while-revalidate=3600 tells the CDN: when the object expires, keep serving the stale copy for up to an hour while you fetch a fresh one in the background. Users never wait on your origin. Effective latency for cacheable content becomes edge latency, always, even at the moment of expiry.

stale-if-error=86400 tells the CDN: if the origin returns a 5xx or times out, serve the stale copy for up to a day. This is a free availability layer. Your origin can be fully down and your public pages keep loading. It converts many incidents from "site down" into "content slightly outdated," which is a much better conversation to have with your CEO.

Cache-Control: public, s-maxage=300, stale-while-revalidate=3600, stale-if-error=86400

Invalidation: the part everyone gets wrong

Long TTLs are only safe if you can purge precisely. There are three levels of purging maturity:

  1. Purge everything. Simple, brutal, and it triggers a full stampede of origin traffic while the cache refills. Acceptable for tiny sites only.
  2. Purge by URL. Better, but one piece of content rarely lives at one URL. A product appears on its detail page, category pages, search results, the sitemap, and three API endpoints.
  3. Purge by surrogate key (tag). The correct answer. Tag every response with the entities it contains, then purge by tag when an entity changes.

Tagging looks like this:

Surrogate-Key: product-8841 category-shoes price-list

When product 8841 changes price, one API call purges every page and API response that ever mentioned it, across every PoP, typically within a couple of seconds on Fastly or Cloudflare Enterprise. Your CMS or admin panel fires the purge in the same transaction as the write. This is what lets you run s-maxage=86400 on pages that feel dynamic: the TTL is just a backstop, and tags do the real freshness work.

Wire purging into your deploy pipeline too. A deploy that changes HTML templates should purge the HTML tag class as its final step, after the new origin version is live everywhere. Do it in the wrong order and you cache the old version again immediately. This sequencing problem is the same class of issue we walk through in zero-downtime deployments.

Caching dynamic and personalized content

"Our pages are personalized, we cannot cache" is almost always false. You cannot cache the whole page. You can cache almost all of it.

  • Split the page. Cache the anonymous shell at the edge with a long TTL. Fetch the personalized fragments (cart count, username, recommendations) from a small API after load, or stitch them in with edge-side includes or edge compute.
  • Compute at the edge. Cloudflare Workers and Fastly Compute let you run logic in front of the cache: read a cookie, pick a variant, rewrite a header, then serve the matching cached object. A/B tests and geo pricing stop being cache killers and become key dimensions.
  • Micro-cache your APIs. A public API cached for even 5 seconds collapses a spike of thousands of identical requests into a handful of origin hits. Nginx can do this in front of any backend:
proxy_cache_path /var/cache/nginx keys_zone=api:50m;

location /api/catalog {
  proxy_cache api;
  proxy_cache_valid 200 5s;
  proxy_cache_use_stale error timeout updating;
  proxy_cache_lock on;
  proxy_pass http://backend;
}

proxy_cache_lock matters more than it looks. It coalesces concurrent misses for the same key into a single origin request. Without request coalescing, a popular object expiring under load sends a thundering herd at your origin, and that is one of the classic CDN failure modes.

Failure modes to design against

  • Cache stampede. A hot object expires, thousands of requests miss simultaneously, origin falls over. Defenses: request coalescing, stale-while-revalidate, and jittered TTLs so related objects do not expire together.
  • Cache poisoning. An attacker finds an unkeyed input (a header your app reflects but your key ignores) and gets a malicious response cached for everyone. Defense: the whitelist key discipline above, and never reflect request input you do not key on.
  • Caching private data publicly. One missing private directive on an authenticated route and user A sees user B's account page. Defense: default deny. Force no-store on everything, then explicitly opt routes into caching. Audit for Set-Cookie on cacheable responses; most CDNs will refuse to cache them, and you should verify yours does.
  • Negative caching of errors. Your origin has a bad minute, returns 500s, and the CDN caches the error page for an hour. Set explicit, short TTLs for 4xx and 5xx (seconds, not minutes).

A rollout checklist that works

  1. Inventory your content by mutability class: hashed assets, media, rendered HTML, API responses, authenticated pages.
  2. Turn on content hashing in your build and ship assets with public, max-age=31536000, immutable.
  3. Define the cache key: whitelist query parameters, sort them, strip tracking parameters, normalize device and geo into small custom headers.
  4. Set HTML to s-maxage plus stale-while-revalidate plus stale-if-error, with browser TTL at zero.
  5. Implement surrogate keys in your application and wire purge calls into every content write and into the deploy pipeline.
  6. Force private, no-store as the default for anything behind authentication, then opt public routes in one by one.
  7. Load test the miss path: your origin must survive a full purge during peak traffic, because someday it will happen.
  8. Run a game day: purge storm, origin outage with stale-if-error, and a poisoning probe against your key.

Measure offload, not just hit ratio

A 95 percent hit ratio sounds great and can still hide a disaster if the missing 5 percent is your heaviest endpoint. Track three things per content class: origin offload (bytes and requests the origin never saw), p95 edge TTFB by region, and purge propagation time. Feed CDN logs into the same pipeline as your application telemetry so a hit ratio drop shows up next to the deploy that caused it. Egress pricing also makes this a finance topic: CDN egress is usually far cheaper than cloud origin egress, a lever we break down in the cloud cost optimization playbook.

How Innovation T can help

Innovation T designs and operates edge architectures for companies that need global speed without global infrastructure teams: cache key design, surrogate key purging, edge compute on Cloudflare and Fastly, and the CI/CD wiring that keeps it all correct. We have done this on e-commerce platforms, SaaS products, and media sites, and we know where the footguns are because we have stepped on most of them.

If your TTFB is ugly outside your home region, or your origin bill grows linearly with traffic, talk to us. See what we build on our services page or contact us for an architecture review.

#CDN#edge caching#performance#infrastructure

Bereit, mit Innovation T zu bauen?

Ob Sicherheit, Wachstum oder Engineering, unser Team hilft Ihnen, es gut umzusetzen.