Skip to content
Katabench
Try free
9 min read The Katabench team

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

95% hit rate, one worked example

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 CDN caches exactly what the response headers allow. Cache-Control is the contract; the hit rate is the consequence.

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-Cookie on 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-Cookie header 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: Cookie or Vary: 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=newsletter and ?utm_source=forum are 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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.