DevLift
Back to Blog

How CDNs Work Under the Hood

From BGP Anycast routing to tiered cache hierarchies and Surrogate-Key purge — how CDNs actually move traffic from your origin to the nearest edge server.

Admin
May 1, 20269 min read2 views

How CDNs Work Under the Hood

You deploy your Next.js app to a single server in us-east-1. A user in São Paulo loads your homepage. Without a CDN, their browser needs a TCP handshake, then a TLS handshake, then the actual request — three sequential round trips to a machine thousands of miles away before a single byte of your page arrives. Take your origin RTT, multiply by three: that's the floor, and nothing you do in application code moves it.

Add Cloudflare or Fastly in front and that same user is now handshaking with a box in São Paulo. Here's what actually happens when that works.

The Mental Model

A CDN is a globally distributed network of cache servers, organized into Points of Presence (PoPs). The fundamental job of every PoP is to intercept requests before they reach your origin server, serve cached content when it has it, and forward only cache misses upstream.

Rendering diagram...

There are two distinct problems a CDN has to solve: how to get the user's request to the nearest PoP, and what to do once it arrives. Most mental models stop at "it serves from a nearby server" and skip both.

Getting to the Nearest Edge

GeoDNS

When you point your domain at a CDN, you're delegating DNS authority to them. When a user's browser resolves assets.example.com, the CDN's authoritative DNS server looks at the user's resolver IP address, maps it to a geographic region, and returns the IP of the closest PoP.

Rendering diagram...

GeoDNS is straightforward but has real tradeoffs. The CDN sees your recursive resolver's IP, not your actual IP — which is why users in one city sometimes get routed to a PoP in another. EDNS Client Subnet (RFC 7871) exists precisely to fix this: the resolver forwards a truncated prefix of the client's network alongside the query so the authoritative server can geolocate the client rather than the resolver. It only helps when both ends implement it, which is not a given.

The other tradeoff is failover. If a PoP goes down, traffic reroutes only after DNS TTLs expire, so your worst-case outage window is bounded below by your TTL. CDNs keep these TTLs short for exactly that reason, but "short" is still a window in which clients hold an address that no longer answers.

BGP Anycast

This is how Cloudflare operates, and it's more elegant. Every Cloudflare PoP advertises the same IP prefix to the internet via BGP. Backbone routers see multiple paths to that one prefix and pick one using BGP's best-path algorithm — so your packets land at whichever PoP is nearest in routing terms, with no per-user steering decision anywhere in DNS.

Failover doesn't wait on a TTL. If a PoP goes offline it withdraws its prefix advertisement, and traffic follows the next-best path as soon as the surrounding routers reconverge.

💡

"Topologically nearest" and "geographically nearest" are different things, and AS-path length is only one of the inputs BGP uses — not even the first one, since operator-configured local preference outranks it. A PoP two AS hops away usually beats one five hops away, but "nearest" is ultimately whatever the routing policies of the networks in between decide. This is why traceroute to a CDN sometimes surprises you.

GeoDNS gives you more control over routing policy (you can steer by load, not just topology) but its failover floor is your DNS TTL. Anycast fails over as fast as the routing table converges, and needs no geo-steering logic in DNS at all — you still do one DNS lookup, it just returns the same answer to everybody.

Inside a PoP: The Cache Hierarchy

A PoP isn't just one server. It's a cluster of edge cache nodes behind local load balancers, often co-located at an internet exchange point (IXP) where ISPs peer directly. This placement matters for two reasons: low latency to end users, and the fact that TCP and TLS terminate at the PoP — not at your origin. A user in Tokyo negotiates a TLS handshake with a server in Tokyo, not one in Virginia.

Most CDNs organize cache in multiple tiers:

TierRole
Edge nodesCache closest to users, one per PoP location
Regional / mid-tierAggregates traffic from multiple nearby PoPs
Origin ShieldSingle designated PoP shielding your origin
OriginYour actual server

The reason for origin shield is the thundering herd problem. Say you have 300 PoPs globally and your homepage cache expires. Without a shield, 300 edge nodes make 300 concurrent requests to your origin. With an origin shield, all 300 edges route their misses through a single shield node instead.

Two separate mechanisms are doing the work here and it pays to keep them apart, because people ship one and expect the other. Shielding is the topology change: Fastly's docs describe requests from across the network funnelling through "a single, designated shield POP" before reaching origin. That collapses 300 caches down to one. Request collapsing is the concurrency change: when several identical misses land on the same cache at the same time, one of them is elected to fetch from the backend and the rest queue on it and get a copy of the answer. Fastly's docs put it plainly: "By default, cache misses will qualify for request collapsing in both VCL and Compute services, when using the readthrough or simple cache interfaces." Shielding gets you from 300 requests to a handful; collapsing is what gets you to one.

Rendering diagram...

How Caching Decisions Are Made

When a request hits an edge node, the CDN computes a cache key — typically the full URL by default, though you can configure it to strip query parameters or include specific headers. It looks up that key in local storage. Cache hit: serve immediately. Cache miss: go upstream.

What ends up cached, and for how long, is controlled by response headers from your origin.

Cache-Control

Two directives matter specifically for CDNs:

Cache-Control: public, max-age=3600, s-maxage=86400
  • max-age=3600 — browsers cache for 1 hour
  • s-maxage=86400shared caches (CDNs, proxies) cache for 24 hours, overriding max-age for them

This lets you keep browser caches short (so users get updates soon after a deploy) while keeping CDN caches warm for a full day. If you omit s-maxage, CDNs fall back to max-age or their platform default.

Cache-Control: no-cache

This is a common footgun. no-cache does not mean "don't cache." RFC 9111 §5.2.2.4 defines it as: the response "MUST NOT be used to satisfy any other request without forwarding it for validation." So: cache it, but check with the origin before serving. The directive that actually prevents storage is no-store (§5.2.2.5), which says a cache "MUST NOT store any part of either the immediate request or the response."

Cache-Control: stale-while-revalidate=60

Serve the stale cached version for up to 60 seconds while fetching a fresh copy in the background (RFC 5861). Users get the old response instantly and never wait on the revalidation. This is one of the most effective performance primitives you can set on frequently-updated content.

⚠️

s-maxage and stale-while-revalidate do not compose, and this is the single most expensive thing on this page to not know. RFC 9111 §5.2.2.10 says s-maxage "incorporates the semantics of the proxy-revalidate response directive" — which means a shared cache must not reuse a stale response without validating with the origin first. That cancels stale-while-revalidate outright. Cloudflare's cache-control docs carry a callout titled exactly "s-maxage disables stale-while-revalidate" for this reason. If you want stale-while-revalidate on a CDN, set the CDN's TTL with CDN-Cache-Control (RFC 9213, the targeted cache-control field, authored by engineers at Akamai, Fastly and Cloudflare) instead of s-maxage.

Conditional Requests: ETag and Last-Modified

When a CDN's cached copy expires, it doesn't re-fetch the full resource blindly. It sends a conditional request with the stored validator:

GET /bundle.js HTTP/1.1
If-None-Match: "abc123def456"

If the content hasn't changed, your origin responds with 304 Not Modified — no body — and the CDN refreshes the TTL on its existing copy. For large assets this matters: revalidating a 2MB JavaScript bundle costs one round trip and a few hundred bytes of headers if the ETag matches, versus re-transferring 2MB on every TTL expiry.

Cache-Tag / Surrogate-Key: Surgical Purge

Standard cache purge is coarse: purge a URL, or nuke everything. Surrogate-Key (Fastly) and Cache-Tag (Cloudflare) let you attach arbitrary tags to cached responses, then purge by tag.

Your origin adds a header to every response:

Surrogate-Key: post-789 author-42 category-tech

When post 789 is updated, you make a single API call:

curl -X POST "https://api.fastly.com/service/{service_id}/purge/post-789" \
  -H "Fastly-Key: $FASTLY_API_KEY"

Every cached object tagged post-789 — the post HTML, its OpenGraph image, any JSON API responses that include it — is purged in one operation. This is the mechanism behind "my CMS webhook purges exactly the pages that changed": the webhook handler maps the edited entry to a tag and fires a single purge, instead of trying to enumerate every URL that embedded it.

Cloudflare's Cache-Tag is comma-separated rather than space-separated, and purges via POST /zones/{zone_id}/purge_cache with a tags array. Check the limits before you design around it: Cloudflare caps the aggregate Cache-Tag header at 16 KB, which its docs put at roughly 1,000 unique tags per response.

The Full Request Flow

Rendering diagram...

Practical Code: CDN-Optimized Headers in Next.js

// app/api/products/route.ts
import { NextResponse } from "next/server";
 
export async function GET() {
  const products = await fetchProductCatalog();
 
  return NextResponse.json(products, {
    headers: {
      // Browsers + any cache that ignores CDN-Cache-Control.
      // Note: no s-maxage here — it would kill stale-while-revalidate.
      "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
      // Targeted at CDN caches only (RFC 9213).
      "CDN-Cache-Control": "public, max-age=3600, stale-while-revalidate=60",
      "Cache-Tag": "product-catalog",
      "ETag": `"${hashCatalog(products)}"`,
    },
  });
}

The obvious-looking version of that header — max-age=300, s-maxage=3600, stale-while-revalidate=60 — is the one to avoid. It reads like "short browser TTL, long edge TTL, plus free stale serving," and instead you get the first two and silently lose the third.

For static assets with content-hashed filenames, go aggressive:

// next.config.ts
const nextConfig = {
  async headers() {
    return [
      {
        source: "/_next/static/:path*",
        headers: [
          {
            key: "Cache-Control",
            value: "public, max-age=31536000, s-maxage=31536000, immutable",
          },
        ],
      },
    ];
  },
};

Practical Implications

Give the CDN its own TTL, explicitly. If you only set max-age, your CDN inherits the browser's TTL. s-maxage does that job, but it drags proxy-revalidate semantics in with it; CDN-Cache-Control does it without side effects. Reach for s-maxage when you're not using stale serving, and CDN-Cache-Control when you are.

The Vary header can silently crater your cache hit rate — or be ignored entirely. On a CDN that honours it, Vary: Accept-Language creates a separate cache entry per language variant. Cloudflare documents the opposite default: it does not consider Vary in caching decisions except for Vary: Accept-Encoding and where you've explicitly configured it. Same header, hit-rate disaster on one CDN and a no-op on another. Read your vendor's docs, not just the RFC.

Origin shield is the most underused CDN feature, and request collapsing is the half of it people forget to check is on. Without either, synchronized cache expiry across hundreds of PoPs can spike your origin at the worst moment.

Debug with response headers, not just latency. X-Cache: HIT, CF-Cache-Status: HIT, and Age: 3421 tell you exactly what the CDN did.

no-cache is not no-store. no-cache revalidates before serving. no-store refuses to store the response at all. They're commonly confused and the consequences show up at the worst times.

immutable is a browser directive, not a CDN one. It tells clients not to send a conditional request even on an explicit refresh. Cloudflare states plainly that it has no effect on its own cache — so on content-hashed asset paths it's still worth setting, just don't expect it to change anything at the edge.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

A monotonic stack maintains elements in order and pops when that order breaks — finding the next greater element for every popped value in O(n) total.
AdminAugust 3, 20265 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read