CDNs
Move the bytes closer to the user — the cheapest 10x in system design, right up to the moment you cache something personal.
A CDN is a cache you don't operate, sitting where the latency is
You already know caching: put a copy of an expensive result somewhere faster than recomputing it. A CDN applies that idea to the one cost you cannot optimise away in your own datacentre — the speed of light. A round trip from Singapore to us-east-1 is ~180 ms on fibre and no amount of Redis fixes it. A CDN is a network of PoPs (points of presence — racks of caching proxies in ~100-600 metros depending on the vendor) that terminate the user's TCP and TLS locally and serve the response from a disk 10 ms away.
Interviewers ask about CDNs because it is the fastest read on whether you think about where a system runs, not just what it does — and because the follow-up ("what happens when a logged-in user's page gets cached?") separates people who have configured a CDN from people who have heard of one.
How the request finds the nearest edge: anycast and DNS steering
There are two mechanisms and good candidates name both.
- Anycast. The CDN announces the same IP prefix via BGP from every PoP. Internet routers each pick the topologically shortest path to that prefix, so a packet addressed to 104.16.0.1 lands in Frankfurt from Berlin and in São Paulo from Rio, with no application logic involved. Failover is a BGP withdrawal: pull the announcement from a PoP and traffic re-converges elsewhere in seconds. The catch is that anycast is stateless routing — a mid-connection path change can land packets at a different PoP, which is why anycast TCP needs careful ECMP consistency (in practice all major CDNs solve this).
- DNS-based steering (unicast). The authoritative DNS server returns a different A record per resolver location, using EDNS Client Subnet to see the real user prefix rather than the resolver's. More control (you can weight by PoP load or cost), but it inherits DNS TTL latency: draining a PoP takes as long as the longest cached TTL, so these deployments run 20-60 s TTLs and pay the extra lookups.
Most large CDNs do both: anycast to reach a metro, then internal layer-4 load balancing inside the PoP. Say "anycast for the coarse routing, DNS or GeoIP steering when I need per-PoP control" and you have covered it.
Push vs pull: who decides what lives at the edge
| Pull (origin-pull) | Push | |
|---|---|---|
| Who populates | First user to request an object; edge fetches and stores it | You upload objects to the CDN ahead of time, usually in CI |
| Cost of a cold object | One user eats the full origin RTT; N PoPs means up to N cold fetches unless there's a shield tier | Zero — it's already there |
| Storage cost | Only what's actually requested | Everything, in every region, whether requested or not |
| Operational load | Near zero — set headers and point DNS at it | A deploy step that can fail, plus lifecycle management |
| Reach for this when | Almost always. It's self-tuning: popular objects are cached, the long tail isn't | Large, predictable, launch-critical objects — a game patch, a video catalogue, a Super Bowl ad you cannot afford to miss on |
The honest answer in an interview is "pull, with an origin shield." A shield is a designated mid-tier PoP that all other PoPs miss through, so a cold object costs one origin fetch globally instead of one per PoP. On a 300-PoP network that is the difference between a launch and an origin outage.
Cache-Control: the directives and what they actually do
| Directive | What it actually does | The part people get wrong |
|---|---|---|
max-age=N | Fresh for N seconds in any cache, including the browser | Once sent, you cannot recall it from a browser. Deploying a bad 1-year max-age is unfixable without changing the URL |
s-maxage=N | Same, but only for shared caches (CDN, proxy). Overrides max-age there | The lever you want: max-age=0, s-maxage=600 means browsers always revalidate but the CDN absorbs the load |
public | Cacheable by shared caches even when the request had an Authorization header | Setting this on an authenticated endpoint is exactly how personalised pages leak |
private | Browser may cache; shared caches must not | It is not a security control — it's a hint. Don't rely on it for secrets |
no-cache | Store it, but revalidate with the origin before every reuse | Does not mean "don't cache". That's no-store. This is the single most common misreading in the whole spec |
no-store | Never write it to disk or memory anywhere | Correct for account pages and API responses with PII; wasteful everywhere else |
must-revalidate | Once stale, you may not serve it — even if the origin is down | Turns an origin outage into a user-visible outage. Usually you want the opposite |
stale-while-revalidate=N | For N seconds past expiry, serve the stale copy immediately and refresh in the background | The highest-value directive in the table and the most under-used — it decouples freshness from latency |
stale-if-error=N | If the origin returns 5xx or times out, keep serving the stale copy for N seconds | Free availability. Your CDN becomes a static failover for the whole site |
immutable | Don't even conditionally revalidate on a user reload | Only safe with content-hashed filenames. Pair with max-age=31536000 |
Vary: H | Adds request header H to the cache key | Vary: User-Agent shatters your hit rate into thousands of fragments. Vary: Accept-Encoding is fine (3 values) |
- Hashed static asset (
/app.9f2c1a.js):Cache-Control: public, max-age=31536000, immutable - Shared HTML or a hot read API:
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400 - Anything user-specific:
Cache-Control: private, no-store— and check that no CDN rule overrides it
ETags and revalidation: paying for freshness in bytes, not seconds
When an object goes stale the edge does not have to re-download it. It
sends a conditional request — If-None-Match: "9f2c1a" — and
the origin replies 304 Not Modified with an empty body if
the ETag still matches. You save the payload (often 99% of the bytes)
but you still pay the full origin round trip, so a 304 costs the same
latency as a 200. That is precisely the gap
stale-while-revalidate closes.
- Strong ETag — byte-for-byte identity, required for range requests and resumable downloads. Usually a content hash.
-
Weak ETag (
W/"abc") — semantically equivalent. Right choice when gzip levels or a timestamp comment make bytes differ while meaning doesn't. - Last-Modified / If-Modified-Since — 1-second granularity, and it lies whenever a deploy rewrites files without changing content. Prefer ETags; keep Last-Modified as a fallback.
mtime-size), two app servers holding identical content
emit different ETags. Every revalidation then misses, the CDN
re-downloads, and you have quietly built a cache that never hits. Derive
the ETag from a hash of the content or from the build ID — something all
replicas agree on.
Choosing a TTL: the question is "how stale is tolerable", not "how fresh can I be"
| Content | TTL | Reasoning |
|---|---|---|
| Hashed JS/CSS/fonts | 1 year, immutable | The URL changes when the bytes change, so staleness is impossible by construction |
| Un-hashed images, PDFs | 1-7 days at the CDN, minutes in the browser | You can purge the CDN in seconds; you cannot purge browsers |
| Marketing / docs HTML | s-maxage=300 + swr=86400 | Editors expect changes within minutes, not instantly; SWR means nobody ever waits for the refresh |
| Product listing / search results | s-maxage=10-60 + swr | 60 s of staleness on a catalogue is invisible to users and removes 99% of origin reads on a hot query |
| Price, inventory count | s-maxage=0-5, or don't cache | Wrong price is a business incident. Cache the page shell, fetch the number client-side |
| Anything per-user | no-store at the CDN | Cache key cardinality equals user count — the hit rate is ~0 even if it were safe |
A useful reframing under pressure: TTL is a budget for how wrong you are willing to be, multiplied by how often the data changes. A 60-second TTL on data that changes hourly is nearly pointless conservatism; a 60-second TTL on data that changes every 200 ms is a deliberate, correct decision to serve approximate data fast.
Cache key design, and the bug that ends careers
The cache key is what the edge hashes to decide "have I seen this
request before?" By default it is roughly scheme + host + path + full
query string, plus whatever Vary adds. Two failure modes
sit on either side of getting it right.
Cache-Control that permits shared caching (or an
origin that omits the header entirely and lets the CDN apply a default
TTL). The defence is layered: default to private, no-store
on every authenticated route; put the CDN in "cache nothing unless the
origin explicitly opts in" mode; and add a synthetic test that logs in
as user A, then requests the same URL as user B and asserts the
response does not contain A's data.
Going the other way, an over-precise key destroys your hit rate. Keys should be normalised before hashing:
- Sort and allow-list query params.
?sort=price&page=2and?page=2&sort=pricemust hash the same, and tracking params likeutm_source,fbclid,gclidmust be stripped — otherwise every ad click creates a unique, permanently-cold cache entry. - Never key on the whole Cookie header. Key on a derived value instead: a boolean
logged-in, or a plan tier. Two variants, not two million. - Collapse device classes. Bucket User-Agent into
mobile|desktop|botat the edge and vary on that, never on the raw string. - Lowercase the host, drop the default port, canonicalise trailing slashes — free hit-rate.
Invalidation: purge is the fallback, versioned URLs are the design
The strongest thing you can say here is that you avoid invalidation
rather than optimise it. Content-addressed URLs
(/static/app.9f2c1a.js, emitted by the bundler) make the
problem disappear: new bytes get a new URL, the old URL stays valid
forever, and rollback is just re-pointing the HTML. The HTML itself is
the only short-TTL object in the system, and it is small.
| Mechanism | Propagation | Reach for this when |
|---|---|---|
| Versioned / hashed URL | Instant, by construction | Default for every build artefact — JS, CSS, images, fonts |
| Purge single URL | Seconds globally | One asset was published wrong; a legal takedown |
| Surrogate-key / cache-tag purge | ~150 ms - a few seconds | The real tool for dynamic sites: tag every response with the entity IDs it contains (Surrogate-Key: product-42 category-9) and purge by tag when the entity changes. One product edit invalidates exactly the pages that mention it |
| Purge everything | Seconds, then a stampede | Almost never. Every PoP simultaneously misses, origin takes 20-50x its normal read load, and you find out whether your database was ever really sized for it |
Edge compute: the CDN stops being read-only
Every major CDN now runs your code in the PoP — Cloudflare Workers, Lambda@Edge and CloudFront Functions, Fastly Compute — typically a V8 isolate or WASM sandbox with sub-millisecond cold start and a ~5-50 ms CPU budget. That converts several origin round trips into edge-local work:
- Cache key rewriting. Normalise the query string, bucket the A/B variant, downgrade a cookie to a boolean — the normalisation rules above, implemented rather than configured.
- Auth at the edge. Verify a JWT signature locally and reject unauthenticated traffic 170 ms before it would have reached your origin. Revocation still needs an origin check, so keep tokens short-lived.
- Personalisation without breaking the cache. Cache one shared, anonymous HTML shell aggressively, then have the Worker stitch in the per-user fragment (name, cart count) from a KV lookup. You get a cacheable page and a personalised one.
- Geo / consent routing, redirects, signed URLs, bot scoring — all decisions that only need the request itself.
Be honest about the limits: edge runtimes have tight memory, no persistent TCP to your primary database (a 180 ms hop back to origin eats the entire win), and eventually-consistent edge KV. Edge compute is for decisions about the request, not for business logic that needs your transactional store.
When a CDN is a 10x win, and when it changes nothing
| Workload | Effect | Why |
|---|---|---|
| Static assets, images, video, downloads | Enormous | 95-99% offload; origin egress drops 20-50x, and CDN egress is often 5-10x cheaper per GB than cloud egress |
| Mostly-anonymous read pages (news, docs, catalogue, marketing) | Large | One cached copy serves millions; the long tail is where hit rate goes, so watch p50 not just totals |
| Read-heavy public API (prices, timetables, feature flags) | Real, with 5-60 s TTLs | The traffic is spiky and repetitive — exactly what a cache is for |
Highly personalised API (/me/feed) | ~Nothing on cacheability | Key cardinality equals user count. You still get TLS termination and a warm origin connection — worth maybe 30-50% off TTFB, not 10x |
| Write-heavy endpoints (checkout, messaging, uploads) | Nothing, by definition | POST/PUT/PATCH are never cached. A CDN can still shed abusive traffic and terminate TLS, but it is not a scaling story |
| Real-time, sub-second freshness (live trading, presence) | Nothing, and it can hurt | Any TTL is too long; caching here converts a latency problem into a correctness problem |
The load-bearing metric in all six rows is the same: hit rate. A CDN at 50% hit rate is a rounding error; at 95% it removes 20x the load; at 99% it removes 100x. When an interviewer asks "how would you know it's working", the answer is hit rate by content type and origin egress per user — not "latency looks better."
Recognizing it in an unseen problem
- Signals: "global users", "millions of images", "video streaming", "our site is slow in India", or a read:write ratio quoted above about 20:1. Any of those and the CDN belongs in your first drawing, before you touch the database.
- The naive design puts one app tier in one region behind a load balancer and tries to fix the 200 ms of physics with more replicas. Replicas cut queue time; they cannot cut propagation delay.
- Distinguishing it from application caching: a CDN caches HTTP responses keyed by URL, near the user; Redis caches arbitrary values keyed by anything, near the service. If the thing you want to reuse isn't addressable by URL, or is per-user, it belongs in Redis — this is a layered-cache question, not an either/or.
- Estimate before you commit. 10 M daily users × 2 MB of assets ≈ 20 TB/day. At cloud egress that's roughly $1,800/day; at 95% CDN offload with cheaper per-GB pricing it's a small fraction of that. Cost is a legitimate reason to reach for a CDN and it scores well.
- The pitfall to name unprompted: personalised content in a shared cache. Say the words "I'd default authenticated routes to
private, no-storeand make the CDN opt-in rather than opt-out" and you have pre-empted the follow-up question. - The second pitfall: full purges and cold-start stampedes. If the design has a global purge in it, pair it with a shield tier or staggered TTLs, or say out loud that the origin must be sized for the miss storm.