How does CDN/edge caching work, and which HTTP headers control it?
CDNs push responses to servers near the user, but only the response headers decide whether they're allowed to, for how long, and for whom.
LEARN BY DOINGPut your intuition to work3 steps
How does CDN/edge caching work, and which HTTP headers control it?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#What a CDN actually does with a response
A CDN (or any "edge" cache — a reverse proxy sitting between your users and your origin servers) keeps copies of responses at points of presence (PoPs) geographically close to users, so a request from Tokyo doesn't have to round-trip to an origin server in Virginia. The mechanism is generic HTTP caching, not magic: the origin's response headers tell every downstream cache — the CDN, corporate proxies, and the browser itself — whether it may store the response, for how long, and under what conditions it must double-check with the origin before reusing it.
On a miss, the edge PoP fetches from origin, stores a copy according to the response headers, and serves it. On a hit, nothing reaches your origin at all — which is the whole point: origin load and latency both drop for anything cacheable.
#The headers that actually do the work
Cache-Control is the header that matters most. Its directives compose:
| Directive | Meaning |
|---|---|
public | Any cache (browser, CDN, proxy) may store this response |
private | Only the end-user's browser may cache it — CDNs and shared proxies must not |
no-store | Never cache this anywhere, not even briefly (auth tokens, payment pages) |
no-cache | May be cached, but must revalidate with the origin before every reuse |
max-age=N | Fresh for N seconds for any cache, browser included |
s-maxage=N | Fresh for N seconds in shared caches (CDN) specifically — overrides max-age for them, letting you cache longer at the edge than in the browser |
must-revalidate | Once stale, must not be served without revalidating, even if the origin is unreachable |
stale-while-revalidate=N | May serve the stale copy for up to N more seconds while fetching a fresh one in the background |
stale-if-error=N | May keep serving the stale copy for N seconds if the origin errors or times out |
immutable | This exact URL's content will never change — skip revalidation entirely, even on a hard refresh |
A few supporting headers round it out: ETag is an opaque fingerprint of the response body; a client sends it back as If-None-Match on the next request, and the origin returns a bodiless 304 Not Modified if nothing changed — the client reuses its stored copy without re-downloading it. Vary tells caches which request headers split the cache key — Vary: Accept-Encoding means the gzip and brotli versions of a response are cached separately; Vary: Accept-Language does the same per locale. Get Vary wrong (forget it, or over-specify it) and you either serve the wrong language to someone or destroy your hit rate by fragmenting the cache per user-agent string. Age is a response header the CDN itself adds, telling you (and any downstream cache) how many seconds ago the origin generated this specific cached copy — invaluable for debugging "why is this stale."
#A real configuration
// Express: static, hashed build assets — safe to cache foreverapp.use("/static", express.static("build/static", {setHeaders: (res) => {res.setHeader("Cache-Control", "public, max-age=31536000, immutable");},}));// An API response that's fine to be up to 60s stale at the edge,// and can serve a stale copy for 5 more minutes while refreshing in the backgroundapp.get("/api/articles/:id", async (req, res) => {const article = await getArticle(req.params.id);res.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300");res.set("Vary", "Accept-Encoding");res.json(article);});// A logged-in user's dashboard — must never be cached by a shared cacheapp.get("/api/me/dashboard", requireAuth, async (req, res) => {const data = await getDashboard(req.user.id);res.set("Cache-Control", "private, no-store");res.json(data);});
At the CDN layer, the equivalent Nginx reverse-proxy cache config looks like:
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g;server {location /api/articles/ {proxy_pass http://origin_upstream;proxy_cache api_cache;proxy_cache_valid 200 60s;proxy_cache_use_stale error timeout updating http_500 http_502 http_503;proxy_cache_background_update on;add_header X-Cache-Status $upstream_cache_status;}}
proxy_cache_use_stale and proxy_cache_background_update are Nginx's version of stale-if-error and stale-while-revalidate — serve what you have, refresh quietly, and only make the user wait when there's truly nothing cached yet.
#Purging is its own problem
Unlike a single Redis instance, a CDN cache is replicated across dozens or hundreds of PoPs. Purging "this URL" after a content update means telling every PoP, and that propagation isn't instantaneous — most CDNs advertise purge propagation in the low seconds, not milliseconds. Because purging by exact URL doesn't scale when one underlying change affects many cached pages (a shared navigation component, say), CDNs support surrogate keys / cache tags: you tag each cached response with the ids of everything that contributed to it (Surrogate-Key: article-42 author-7), and a single purge call for author-7 invalidates every cached page that included that author, without you tracking URLs at all.
The practical skill being tested here isn't memorizing header names — it's reasoning about which cache, at which layer, is allowed to keep which response, and for how long, given who else might request that same URL next.