LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

A Modern CDN, Honestly

cdncachingedgehttpperformancenetworking

A content delivery network is, stripped of marketing, a fleet of reverse-proxy caches positioned close to users, fronted by routing tricks that make “close” happen automatically, and governed by a set of cache-key rules that quietly determine whether you are running a fast website or an expensive distributed 502 generator. Everything else — the image resizer, the edge compute runtime, the DDoS scrubbing, the WAF — is a feature bolted onto that core. The hard parts are not glamorous. The hit rate that makes or breaks your origin bill is decided by how you handle Vary headers, query strings, and cookies, not by which logo is on the dashboard. The latency a user feels is decided by anycast announcements and PoP density, not by the word “edge” in a press release. This post is about the actual mechanics in 2026: how a request finds the nearest point of presence, how a thundering herd of cache misses gets collapsed into one origin fetch, how images get reshaped on the wire, how the cache key is engineered, how purge really propagates, and where Cloudflare, Fastly, CloudFront, and Bunny genuinely diverge rather than where their pricing pages claim they do.


How A Request Reaches The Nearest PoP

The first thing a CDN has to solve is steering: out of dozens or hundreds of points of presence (PoPs), which one answers your packet? There are two mechanisms in use, and most large networks combine them.

The first is anycast. The CDN announces the same IP prefix over BGP from every PoP simultaneously. When your packet leaves your ISP, the internet’s normal shortest-AS-path routing carries it to whichever announcement the routing fabric considers closest — usually, but not always, the geographically nearest one. Cloudflare and Fastly lean heavily on anycast. The appeal is that there is no extra DNS round trip and no steering server in the critical path: the network is the load balancer. The honest caveat is that “closest by BGP” is not “closest by latency.” BGP picks shortest AS-path, and an ISP with a cheap-but-circuitous transit relationship can hand your packet to a PoP two countries away because that path crosses fewer autonomous systems. Anycast also makes long-lived flows fragile in theory, since a mid-connection route change can re-pin you to a different PoP and break TCP state — in practice route stability over a session’s lifetime is high enough that this is rare, and QUIC’s connection IDs make it survivable when it does happen. If you want the gory detail of why BGP chooses what it chooses, see Anycast Explained and BGP for Engineers.

The second mechanism is DNS-based steering. Here the CDN runs an authoritative DNS service that inspects the resolver’s source address (or the EDNS Client Subnet option, when the resolver forwards it) and returns the IP of a PoP it has decided is best for that client. CloudFront and Akamai have historically relied on this. DNS steering can incorporate real-time signals anycast cannot: PoP health, current load, RTT measurements, even capacity reservations. Its weaknesses are equally real. DNS TTLs mean steering decisions are sticky for the cache duration; a client behind a public resolver like 8.8.8.8 that does not forward ECS may be steered based on Google’s location rather than the user’s; and you pay an extra resolution round trip before the first byte. In practice the big networks blend both — anycast to get you to a regional PoP cheaply, DNS or in-PoP logic to refine from there.

Either way, once you land on an edge PoP, you are not necessarily talking to a machine that has your object. Modern CDNs run a tiered cache (also called parent or shield caching). The edge PoP you hit is the lowest tier. On a miss it does not go straight to your origin — it asks a designated upstream parent PoP, often a large regional hub with a much higher hit rate because it aggregates misses from many edges. Only if the parent also misses does the request reach origin. This hierarchy is the single most effective tool for protecting an origin, because it funnels the long tail of edge misses through a small number of well-warmed caches.

                         ┌─────────────────────────────────────────┐
   client ── anycast ──▶ │  EDGE PoP (tier 1, nearest)              │
   (BGP / DNS steer)     │  cache lookup on computed cache key      │
                         └───────────┬───────────────┬─────────────┘
                                     │ HIT           │ MISS
                                     ▼               ▼
                              serve from        ┌──────────────────────┐
                              edge RAM/SSD       │ PARENT / SHIELD PoP   │
                              (lowest latency)   │ (tier 2, regional)    │
                                                 └────────┬─────┬───────┘
                                                          │HIT  │MISS
                                                          ▼     ▼
                                                    serve up   ┌───────────┐
                                                    to edge    │  ORIGIN    │
                                                               │ (your app) │
                                                               └───────────┘

Request Coalescing: Collapsing The Thundering Herd

Here is the failure mode that tiered caching alone does not fix. Suppose a popular object expires, or has never been cached, and ten thousand users request it within the same few hundred milliseconds. Without coordination, every one of those requests is a miss, and every miss is forwarded upstream. Your origin, which was sized to serve one copy of that object, suddenly receives ten thousand simultaneous fetches for it. This is the thundering herd, and it is how CDNs cause outages instead of preventing them.

The fix is request coalescing, sometimes called cache locking or collapsed forwarding. When the first request for an uncached key arrives, the PoP acquires a lock on that key and sends exactly one fetch upstream. Every other concurrent request for the same key blocks on that lock instead of generating its own origin fetch. When the single fetch returns, the object is written to cache and all the waiting requests are satisfied from it. The origin saw one request; the edge served ten thousand responses.

   10,000 concurrent requests for /hero.jpg (uncached)
        │
        ▼
   ┌──────────────────────────────────────────────┐
   │  EDGE PoP                                      │
   │   req #1 ──▶ acquire lock(key) ──▶ ONE fetch ──┼──▶ ORIGIN (1 request)
   │   req #2..10000 ──▶ wait on lock(key)          │
   │   fetch returns ──▶ fill cache ──▶ wake all    │
   └──────────────────────────────────────────────┘
        │
        ▼
   10,000 responses served from one origin fetch

The trade-offs are subtle. Coalescing only works for requests that compute to the same cache key — if your key includes a per-user cookie or an unnormalized query string, ten thousand “identical” requests become ten thousand distinct keys and the herd is not collapsed at all. This is the first place cache-key engineering and origin protection intersect. Coalescing also introduces head-of-line coupling: if the single upstream fetch is slow, every waiting request inherits that latency, and if the fetch fails, you must decide whether the failure fans out to all waiters or whether you serve stale. Most networks pair coalescing with stale-while-revalidate so that a slow or failed revalidation serves the last-known-good copy to the herd rather than an error. Cloudflare exposes this as Origin Cache Control plus its concurrent-streaming-acceleration behavior; Fastly does it in VCL via return(restart) and clustering; Varnish (which underpins Fastly) calls it request coalescing natively and has for years.


The Cache Key Is The Whole Ballgame

If you take one thing from this post: hit rate is determined by your cache key, and your cache key is determined by Vary, the query string, cookies, and any custom logic you add. Two requests that should hit the same cached object will only do so if they compute to the same key. Every accidental source of variation fragments your cache.

Start with Vary. A response of Vary: Accept-Encoding tells the cache to keep separate entries per encoding — sane, you want gzip and brotli stored separately. A response of Vary: User-Agent, on the other hand, is a hit-rate catastrophe: there are effectively unbounded user-agent strings, so you store a separate copy per browser build and your hit rate collapses toward zero. Vary: Cookie is worse still. Audit what your origin emits; frameworks set Vary: Cookie by default far too often.

Next, the query string. By default many CDNs include the entire query string in the cache key, which means /img.jpg?utm_source=twitter and /img.jpg?utm_source=newsletter are two cache entries for one identical image. Marketing parameters, session tokens, and cache-busting junk all fragment the key. The fix is query-string normalization: strip the parameters that do not change the response, sort the ones that do, and key on the result.

sub vcl_hash {
  # Normalize: only these params affect the response; drop everything else.
  set req.http.X-Cache-Key = req.url.path;

  if (querystring.get(req.url, "w") != "") {
    set req.http.X-Cache-Key = req.http.X-Cache-Key + "?w=" + querystring.get(req.url, "w");
  }
  if (querystring.get(req.url, "format") != "") {
    set req.http.X-Cache-Key = req.http.X-Cache-Key + "&format=" + querystring.get(req.url, "format");
  }

  hash_data(req.http.X-Cache-Key);
  # Deliberately NOT hashing the full req.url, so utm_* etc. are ignored.
  return(lookup);
}

Cookies are the third fragmenter and the most dangerous, because they are usually per-user. If your cache key includes Cookie: session=..., every user gets a private cache that never shares with anyone, and your hit rate for “public” assets is whatever fraction of users happen to have no cookie. The discipline is to strip cookies for cacheable paths: at the edge, delete the Cookie header before lookup for anything under /static/, /assets/, /_next/, and so on, and only forward cookies for genuinely dynamic, personalized routes.

sub vcl_recv {
  if (req.url.path ~ "^/(static|assets|_next)/") {
    unset req.http.Cookie;        # public assets must never vary per user
  }
}

Cloudflare exposes the same concepts through its dashboard and the Cache Rules / Custom Cache Key features: you choose which query parameters to include or exclude, whether to ignore cookies, and you can build a key from specific headers or even request body for the API-caching case. The honest tension here is hit rate versus personalization. The more you strip to maximize sharing, the more you risk serving one user’s content to another. The cardinal sin is caching a personalized response under a public key — leaking a logged-in user’s account page to anonymous visitors. The rule is simple to state and easy to violate: anything keyed without the identity must be identical for every identity.


Cache-Control: The Directives That Actually Matter

The origin speaks to the cache through Cache-Control, and the distinction that trips people up is max-age versus s-maxage. max-age is the freshness lifetime for private caches (the browser). s-maxage overrides it for shared caches (the CDN). This lets you tell the CDN to hold an object for a day while telling the browser to revalidate quickly, which is usually what you want: long edge TTL for hit rate, short browser TTL so a purge actually reaches users.

The directive that changed CDN operations is stale-while-revalidate (RFC 5861). It lets a cache serve a stale response immediately while asynchronously fetching a fresh one in the background. The user never waits on the origin; the next user gets the refreshed copy. Paired with stale-if-error, the cache can also keep serving stale content when the origin is down, turning an origin outage into a graceful degradation instead of a 5xx storm.

Cache-Control: public, s-maxage=86400, max-age=60, stale-while-revalidate=600, stale-if-error=86400

That single header says: shared caches keep it fresh for a day, browsers for a minute, serve stale for up to ten minutes while revalidating in the background, and serve stale for up to a day if the origin errors. The table below is the working set of directives and what each one actually does in a shared cache.

Directive Applies to Effect in a CDN Honest gotcha
max-age=N private + shared (if no s-maxage) Freshness lifetime in seconds Often set too high on HTML, making purges feel ineffective in browsers
s-maxage=N shared only Overrides max-age at the CDN Ignored by browsers; not all proxies honor it
public both Explicitly cacheable even with auth headers Required to cache responses to authenticated requests
private browser only Forbids shared caching Frameworks emit this by default; kills CDN caching silently
no-cache both Must revalidate before reuse Not “do not store”; commonly confused with no-store
no-store both Never write to cache at all The real kill switch
stale-while-revalidate=N shared Serve stale, refresh in background The single biggest p99 win for dynamic content
stale-if-error=N shared Serve stale when origin errors Turns origin outages into soft failures
immutable browser Skip revalidation entirely until expiry Only safe with content-hashed filenames

You can confirm what is happening with a plain curl. The cf-cache-status header (Cloudflare; Fastly uses x-cache, CloudFront uses x-cache: Hit from cloudfront) and the age header tell you whether you hit, and how long the object has been sitting in cache.

$ curl -sI https://example.com/assets/app.4f2c9.js
HTTP/2 200
cache-control: public, max-age=31536000, immutable
cf-cache-status: HIT
age: 48213
vary: accept-encoding
content-type: application/javascript

A cf-cache-status: MISS followed by HIT on the second request is the baseline sanity check. DYNAMIC means the CDN decided the response was not cacheable — usually because of a Set-Cookie, a private directive, or a Vary it could not satisfy. EXPIRED followed by a quick refresh is stale-while-revalidate doing its job. BYPASS means a rule told it to skip cache. If you are debugging a low hit rate, this header trio (cf-cache-status, age, vary) is where you start, every time. The toolkit for poking at this end to end overlaps with the general one covered in Traceroute, Ping, and the Network Troubleshooting Toolkit.


Image And Video Transformation At The Edge

The modern CDN does not just cache bytes; it manufactures them. Image transformation at the edge means the origin stores one high-resolution master and the edge produces every variant — resized, cropped, recompressed, and format-negotiated — on the fly, then caches each variant under its own key.

The format negotiation piece is content negotiation done right. The browser sends Accept: image/avif,image/webp,image/*, the edge picks the best format that client supports, transcodes the master, and serves AVIF to Chrome, WebP to slightly older clients, and JPEG to the rest — all from one URL. AVIF typically lands 20 to 50 percent smaller than equivalent-quality WebP, which is itself smaller than JPEG, so this is real bandwidth saved. The cost is that the cache key must now include the negotiated format (or you Vary: Accept, carefully scoped to the formats you actually emit so you do not fragment on the full header), and the first request for each variant pays a transcode-latency penalty. A cold AVIF encode of a large image is not free; it can add tens to low-hundreds of milliseconds, which is why coalescing matters here too — you want one transcode per variant, not one per concurrent request.

$ curl -sI 'https://cdn.example.com/cdn-cgi/image/width=800,format=auto/hero.jpg' \
       -H 'Accept: image/avif,image/webp,image/*'
HTTP/2 200
content-type: image/avif
cf-cache-status: HIT
vary: accept
cache-control: public, max-age=31536000

For video, the edge does packaging and sometimes transcoding. The master is a mezzanine file or a set of pre-encoded renditions; the edge packages them into HLS (.m3u8 manifests plus .ts or fMP4 segments) or DASH (.mpd plus segments) on demand, so you store one ladder and serve both protocols. Segments are extremely cache-friendly — they are immutable, content-addressed, and requested by thousands of viewers in the same minute, which is the ideal coalescing-and-tiered-cache workload. Manifests are short-TTL or no-cache for live, long-TTL for VOD.

Both image and video transformation lean on signed URLs for access control, because once the edge can synthesize arbitrary variants, an open transform endpoint is a denial-of-wallet vector: an attacker requests ?width=1 through ?width=9999 and forces thousands of distinct transcodes, each a cache miss and an origin/compute hit. Signed URLs embed an HMAC over the path, parameters, and an expiry, computed with a secret the attacker does not have. The edge verifies the signature before doing any work.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import hmac, hashlib, base64, time

SECRET = b"keep-this-server-side"

def sign(path: str, params: str, ttl: int = 300) -> str:
    expires = int(time.time()) + ttl
    msg = f"{path}?{params}&expires={expires}".encode()
    sig = base64.urlsafe_b64encode(
        hmac.new(SECRET, msg, hashlib.sha256).digest()
    ).rstrip(b"=").decode()
    return f"{path}?{params}&expires={expires}&sig={sig}"

# /cdn-cgi/image/width=800,format=auto/hero.jpg?...&expires=...&sig=...

The honest trade-off: signed URLs are not cacheable across users if the signature is part of the key, so you sign the parameters that matter and let the signature live outside the cache key (validated, then stripped before hashing). Get that wrong and every user gets a private entry and your hit rate dies — the same cache-key discipline from the previous section, applied to security.


Invalidation And Purge: The Hard Problem, Honestly

There are two hard problems in computer science, and cache invalidation is reliably one of them on any given day. A CDN gives you three tools, in increasing order of operational nicety.

The blunt tool is TTL expiry: set a short s-maxage and accept that content can be stale for up to that long. Cheap, requires no API call, but couples freshness to hit rate inversely — short TTL means fresh and low hit rate, long TTL means high hit rate and stale.

The surgical tool is explicit purge. You call an API to evict a specific URL. This works but scales badly: purging by URL means you must know every URL affected by a change, including every transformed image variant and every query-string permutation, which you usually do not.

The tool that actually works at scale is tag-based purge (also called surrogate keys). The origin attaches tags to each response via a header — Surrogate-Key: product-42 category-shoes on Fastly, Cache-Tag: product-42,category-shoes on Cloudflare Enterprise — and you later purge everything carrying a tag with a single call. Change product 42 and purge the product-42 tag; every page, fragment, and image that referenced it is evicted at once, regardless of URL.

# Fastly: tag responses at the origin, then purge by surrogate key
# Origin response header:
Surrogate-Key: product-42 category-shoes brand-acme

# Purge everything tagged product-42:
$ curl -X POST \
    -H "Fastly-Key: $FASTLY_API_TOKEN" \
    -H "Surrogate-Key: product-42" \
    https://api.fastly.com/service/$SERVICE_ID/purge
# Cloudflare Enterprise: purge by Cache-Tag
$ curl -X POST \
    "https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache" \
    -H "Authorization: Bearer $CF_API_TOKEN" \
    -H "Content-Type: application/json" \
    --data '{"tags":["product-42"]}'

The honest part is purge propagation latency. “Instant purge” is a marketing claim with an asterisk. Fastly’s instant purge genuinely lands in the low hundreds of milliseconds globally because it built a dedicated purge-distribution network; that is one of its real differentiators. Cloudflare’s purge is fast but not uniformly instant across every PoP, and tag-based purge requires the Enterprise plan. CloudFront invalidations are the slow case: they are eventually consistent and historically took on the order of tens of seconds to minutes to fully propagate, and you are billed past a free monthly allotment, which nudges you toward versioned filenames instead of purging. The durable pattern across all of them is content-hashed filenames plus long immutable TTLs for assets (app.4f2c9.js), so you never purge — you just deploy a new filename — and reserve tag-based purge for HTML and API responses where the URL must stay stable.


Where The Providers Actually Differ

The dashboards converge; the substance does not. The real differences are in the edge compute model, how much control you get over the cache key, and how the bill is computed.

Cloudflare Fastly CloudFront Bunny
Edge compute Workers (V8 isolates, JS/Wasm) Compute (Wasm, Rust/Go/JS) + VCL Lambda@Edge + CloudFront Functions Edge Scripting (Deno-based, newer)
Cold starts Effectively none (isolates) Effectively none (Wasm) Lambda@Edge has real cold starts Low (isolate-based)
Cache-key control Cache Rules / Custom Cache Key (no raw config language for free tiers) Full VCL — total control Cache policies + Functions Good via dashboard + URL tokens
Tag/surrogate purge Cache-Tag (Enterprise only) Surrogate-Key (all plans, sub-second) URL/path invalidation only, eventual Tag purge supported
Pricing model Flat/bundled, generous free tier, request-priced compute Request + compute + bandwidth, premium Per-region egress + request + invalidation Pay-as-you-go, cheapest egress
Egress reality No egress fees on standard plans Mid-to-high per GB Highest, region-dependent egress Lowest published per-GB rates
Best fit General web, security, bundled simplicity Programmable edge, low-latency purge Deep AWS integration Cost-sensitive bandwidth-heavy

The VCL versus Workers versus Compute distinction is the one that shapes your architecture. Fastly’s VCL is a configuration language that runs at request time and gives you total, declarative control over caching behavior — vcl_recv, vcl_hash, vcl_fetch, vcl_deliver are hooks where you rewrite keys, strip cookies, restart requests, and decide cacheability with surgical precision. If your problem is “I need exact control over the cache key and the request lifecycle,” VCL is unmatched, and Fastly’s Compute@Edge (Wasm) extends that to arbitrary code. Cloudflare’s Workers are full JavaScript/Wasm running in V8 isolates, with the caches.default API to read and write the edge cache programmatically. Isolates have no meaningful cold start because there is no container to boot — a real advantage over Lambda@Edge, where cold starts are a genuine tail-latency problem.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Cloudflare Worker: explicit cache read/write with a normalized key
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    // Build a normalized cache key: drop tracking params, keep what matters.
    const keyUrl = new URL(url.origin + url.pathname);
    for (const p of ["w", "format"]) {
      if (url.searchParams.has(p)) keyUrl.searchParams.set(p, url.searchParams.get(p));
    }
    const cacheKey = new Request(keyUrl.toString(), request);
    const cache = caches.default;

    let resp = await cache.match(cacheKey);
    if (resp) return resp;                       // HIT

    resp = await fetch(request);                 // MISS -> origin
    resp = new Response(resp.body, resp);
    resp.headers.set("Cache-Control", "public, s-maxage=86400, stale-while-revalidate=600");
    ctx.waitUntil(cache.put(cacheKey, resp.clone())); // fill async, don't block
    return resp;
  },
};

Bunny is the honest dark horse. It lacks the deepest programmability of Fastly and the ecosystem of Cloudflare, but its egress pricing is dramatically lower and its feature set covers the common cases — tag purge, image optimization, token authentication, a newer edge-scripting runtime. For bandwidth-heavy workloads where you are not writing complex edge logic, the per-GB difference dwarfs every other consideration. Which is the real lesson about pricing: CDN bills are dominated by egress, and egress prices vary by an order of magnitude between providers. CloudFront’s egress and per-region pricing make it expensive at scale unless you are deeply committed to AWS and benefit from the integration; its strength is that the rest of your stack is already there. The compute pricing is usually a rounding error next to the bandwidth line.


When A CDN Does Not Help

Honesty requires saying this plainly: a CDN is not free performance, and there are cases where it adds latency and cost for nothing. If your traffic is overwhelmingly dynamic, personalized, and uncacheable — a logged-in dashboard where every byte varies per user — the CDN cannot cache anything and is reduced to a reverse proxy that adds a hop. You may still want it for TLS termination, DDoS absorption, and the edge-compute platform, but do not expect cache benefit that the cache key forbids by construction.

If your users are geographically concentrated near your origin, the latency win from edge proximity is small, and the CDN’s added complexity (cache invalidation, debugging DYNAMIC statuses, an extra failure domain) may not pay for itself. If your origin already sits in the same region as nearly all your users, the math changes.

And there is a real failure mode where the CDN makes things worse: a misconfigured cache key that caches a personalized or authenticated response under a public key, leaking data between users — the single most dangerous CDN bug, and one that ships to production regularly because it is invisible until someone sees another person’s account page. The defenses are the same discipline throughout this post: strip cookies only where it is safe, never cache responses carrying Set-Cookie, scope Vary tightly, and treat any response that depends on identity as private unless you have proven it does not. The deeper transport story underneath all of this — how connections multiplex and recover — is its own subject; see QUIC and HTTP/3 for why the protocol layer increasingly shapes what the edge can do.


Verdict

A modern CDN is a cache-key engineering problem wearing a networking costume. The anycast and DNS steering get you to a nearby PoP, the tiered cache and request coalescing protect your origin from the herd, and the image and video transformation turn the edge from a passive store into an active factory — but every one of those wins is gated by whether your cache key is right. Get Vary, query strings, and cookies wrong and you will have a slow, expensive CDN that occasionally leaks user data. Get them right, lean on stale-while-revalidate for graceful degradation, version your assets so you never purge them, and reserve tag-based purge for the content that genuinely needs stable URLs. On providers: pick Fastly when you need surgical cache control and sub-second purge, Cloudflare when you want bundled simplicity and zero-egress economics with first-class isolate compute, CloudFront when you are already living inside AWS, and Bunny when bandwidth cost is the dominant term and your edge logic is simple. The dashboards will keep converging; the cache key, the egress bill, and the purge latency are where the truth stays.


Sources

Comments