CDN and edge caching: what belongs at the edge
CDN edge caching is HTTP caching at scale. Which Cache-Control directives matter, what never belongs at the edge, and how a 95% hit rate changes the origin.
The product page is on the front page of a large forum, the origin servers are at full CPU
rendering the same HTML 10,000 times a second, and someone points out that there is a CDN in front
of all this. There is. Its dashboard says the hit rate is 3 percent. Every one of those 10,000
requests travels to the edge, looks at the response the origin sent last time, finds a Set-Cookie
header on it, and forwards the request across an ocean to the origin as if the CDN did not exist.
That is the shape of most CDN disappointments. A CDN is not a box that makes things fast; it is a very large HTTP cache that does exactly what your response headers permit and nothing more. Edge caching is HTTP caching at planetary scale, and the header is the contract.
The header is the contract
The rules a CDN follows are the ones in RFC 9111, the HTTP caching specification, plus a few extensions. The directives that decide almost everything:
| Directive | What it tells a shared cache |
|---|---|
public |
This response may be stored by a shared cache such as a CDN |
private |
Only the user's browser may store it; the edge must pass it through |
max-age=N |
Fresh for N seconds in any cache |
s-maxage=N |
Fresh for N seconds in shared caches only, overriding max-age there |
no-cache |
Store it, but revalidate with the origin before every reuse |
no-store |
Do not store it anywhere |
stale-while-revalidate=N |
Serve the stale copy for up to N seconds while fetching a fresh one in the background |
immutable |
The body will never change at this URL, so do not bother revalidating |
Two more headers finish the contract. ETag gives the response a version identifier, so a cache
holding an expired copy can ask the origin whether it is still current with If-None-Match and
receive a bodyless 304 Not Modified instead of the full response. And Vary names the request
headers that change the response, which tells the cache how many separate copies to keep.
CDN request path
The header decides which tier answers
Client
browser or app
Edge PoP
nearest point of presence
Origin shield
one cache in front of origin
Origin
your app servers
Hit
about 20 ms
client → edge → client
Cache-Control: public, s-maxage=300 Age: 41 tells the client how long the copy has been sitting at the edge
Miss
about 150 ms plus origin time
client → edge → shield → origin
Cache-Control: private or Set-Cookie: ... either header makes the edge a proxy; every request pays the full trip
The arithmetic that makes the CDN worth its invoice hangs off that contract. At 10,000 requests per second and a 95 percent hit rate, the origin sees 500 per second, a twentieth of the load, and that is the number the load balancer behind it has to spread. A user near an edge location gets an answer in a 20 millisecond round trip instead of a 150 millisecond trip across a continent, before the origin has done any work at all. Drop the hit rate to 3 percent and the origin sees 9,700 per second plus the CDN's own overhead. Same CDN, same traffic, opposite outcome, decided by a header.
Three kinds of content, three policies
Almost everything an application serves falls into one of three classes, and each has a policy that is close to always right.
Fingerprinted assets are files whose URL contains a hash of their content: app.3f9c1e.js,
logo.a81b.svg. When the content changes, the URL changes, so the old URL can be cached forever:
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = context =>
{
if (context.Context.Request.Path.StartsWithSegments("/assets"))
{
context.Context.Response.Headers.CacheControl =
"public, max-age=31536000, immutable";
}
}
});
A year of freshness plus immutable (RFC 8246) means
the browser does not even revalidate on reload. In .NET 9, MapStaticAssets fingerprints the files
and emits these headers for you; the policy is the same either way.
HTML and other rendered pages change on a schedule you do not control, so they get a short
shared TTL, an ETag, and permission to serve stale for a moment while refreshing:
app.MapGet("/products/{id:guid}", async (
Guid id,
HttpContext http,
CatalogDb db,
CancellationToken cancellationToken) =>
{
var product = await db.Products
.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new { p.Id, p.Name, p.Price, p.Version })
.FirstOrDefaultAsync(cancellationToken);
if (product is null)
{
return Results.NotFound();
}
var etag = new EntityTagHeaderValue($"\"{product.Version}\"");
var cacheControl = new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromSeconds(30),
SharedMaxAge = TimeSpan.FromSeconds(300),
};
cacheControl.Extensions.Add(new NameValueHeaderValue("stale-while-revalidate", "60"));
var response = http.Response.GetTypedHeaders();
response.ETag = etag;
response.CacheControl = cacheControl;
var ifNoneMatch = http.Request.GetTypedHeaders().IfNoneMatch;
if (ifNoneMatch.Any(tag => tag.Compare(etag, useStrongComparison: true)))
{
return Results.StatusCode(StatusCodes.Status304NotModified);
}
return Results.Ok(product);
});
The browser keeps the page for 30 seconds, the edge keeps it for five minutes, and when the edge copy expires it can hand out the stale version for one more minute while a single background fetch refreshes it. When the edge does revalidate, the exchange is cheap:
GET /products/9d2e1c4a-7b3f-4e8d-a1c5-6f2b9e0d3a71 HTTP/1.1
Host: shop.example
If-None-Match: "41"
HTTP/1.1 304 Not Modified
ETag: "41"
Cache-Control: public, max-age=30, s-maxage=300, stale-while-revalidate=60
No body, no rendering, one database read to confirm the version. The Version column bumps on
every change, so the tag is honest without hashing the body.
API responses are the class to be suspicious of. Most are per-user or per-permission, and the
correct header is no-store, or private when the browser may keep it. Cache an API response at
the edge only when the data is genuinely the same for everyone, the URL is the complete cache key,
and you have decided what staleness you are willing to serve. A
URL shortener redirect qualifies: the same answer for everyone,
keyed by the path alone. "It is a GET" is not a caching policy.
The CDN does not decide what to cache. Your response headers do, and one stray
Set-Cookieon a cacheable page turns a planet of edge servers into an expensive proxy.
What must never be cached at the edge
Anything that depends on who is asking. A response that varies by session, by Authorization
header, or by a feature flag tied to the user must not be stored in a shared cache, because the
edge will happily serve the first user's account page to the second. The protection is
Cache-Control: private or no-store on every such response, and it must be there by default,
not added when someone remembers.
The subtler killers are the ones that do not look like caching decisions:
- A
Set-Cookieheader on an otherwise cacheable page. Most CDNs refuse to store a response that sets a cookie, correctly, because the cookie is probably a session. A framework that touches session state on every request does this to every page. Vary: CookieorVary: User-Agent. Each distinct value becomes a separate cache entry, and with cookies that means one entry per visitor: a hit rate near zero with a full cache.- Query strings in the cache key.
?utm_source=newsletterand?utm_source=forumare the same page and two cache entries unless the CDN is told to ignore or sort the parameters.
The fix for personalized pages is to stop pretending they are one response. Cache the shell at the
edge and fetch the personalized fragment, the cart count or the greeting, from an endpoint marked
no-store.
Purge or version
Expiring content early is a purge, and a purge is a request to hundreds of edge locations that takes time to propagate and is easy to forget. Versioned URLs need no purge at all, which is why fingerprinted assets are the easy class. For HTML you cannot rename, keep the TTL short enough that you rarely need to purge, and prefer a soft purge (mark stale, refresh on next request) over a hard delete, so a burst of traffic after the purge does not all miss at once.
That burst is the last thing the contract has to handle. When a popular object expires, every edge
location that holds it misses at roughly the same moment, and each one asks the origin. With a
hundred locations that is a hundred simultaneous requests for one page, multiplied by however many
pages expire together. An origin shield is a single intermediate cache in front of the origin that
all edges miss to, so those hundred requests collapse into one origin fetch. Within one location,
request coalescing does the same for concurrent misses, and stale-while-revalidate means the
refresh happens once in the background while users keep getting the stale copy. If the shield in
the diagram absorbs 80 percent of the 500 misses, the origin serves 100 requests per second for
10,000 at the edge. The same herd shows up in application caches, where
single-flight and jitter play the shield's role.
Serve from the edge, then check the arithmetic
Knowing the directives is table stakes. The skill is looking at a system and deciding which responses can live at the edge, which must reach the origin, and what the origin looks like once the hit rate is decided. Katabench's System Design Studio has a fundamentals challenge called Serve from the Edge: place a CDN in front of the app tier on the canvas, route the static and cacheable paths through it while authenticated traffic still reaches the origin, and let the deterministic capacity simulation report origin throughput and p99 latency under a traffic scenario. Route everything through the origin and the simulation shows which component saturates first; route the personalized path through the CDN and an authored rule flags the bypass. The how it works page explains what the rules and the simulation check, and what they do not. The headers above are the part you write; the arithmetic is the part the model makes visible.