System Design Case Studies 2
Design a CDN
Edge caches, cache keys and TTL, purging, request coalescing and hit-ratio arithmetic, with an edge-cache simulation.
On this page
In one line: a CDN (content delivery network) keeps copies of your files in many cities so users get them from a nearby cache instead of from your one server. The design is about five things: how a user reaches the nearest cache (DNS and anycast), what makes two requests "the same" (the cache key), how long a copy lives (TTL and purge), how to protect the origin (origin shield and request coalescing), and the arithmetic of hit ratio, because a few percent of hit ratio can mean 5 times less load on your servers.
This is lesson 4 of System Design Case Studies, Part 2. Lesson 1 (key-value store) showed consistent hashing and replicas inside one cluster. A CDN reuses these ideas at the world scale: thousands of caches, each holding a small slice of the content. For the simple "push or pull" overview of CDNs, see the system-design-primer and System Design Lesson 14, How CDNs Make the Web Fast if you want the basics first.
What I ran / checked. I wrote three TypeScript programs and ran them on Node 22.22: an edge-cache simulation (LRU cache, TTL, request coalescing, a shield tier, 1 million requests per run, with a fixed random seed), a hit-ratio calculator, and a cache-key builder. All outputs below are real. All traffic in the simulation is made up: a Zipf distribution (a few objects are very popular, most are rare), so the hit ratios show the shape of the effects and are not a benchmark of any real CDN. I read the proxy_cache_lock text in the nginx docs (nginx/nginx.org on GitHub), the default cache hash in the Varnish docs (varnishcache/varnish-cache), and the CDN section and latency table in the system-design-primer. I did not re-read the HTTP caching RFCs (9111, 5861) or any CDN vendor paper; what I say about s-maxage, stale-while-revalidate, anycast and TLS is from memory and marked.
Words used in this lesson
- Origin is your own server (or object store) that holds the real content. A CDN copies from it.
- Edge (also PoP, point of presence) is a CDN site near users. It has cache servers. A big CDN has hundreds of PoPs.
- Cache hit / miss is when the edge has a fresh copy (hit) or has to go and get one (miss). Hit ratio is hits divided by all requests.
- TTL (time to live) is how long a copy is treated as fresh. After that it is stale.
- Cache key is the text that decides "is this the same object?". Two requests with the same key share one copy.
- LRU (least recently used) is an eviction rule: when the cache is full, throw away the entry that was used longest ago.
- Purge (invalidation) is telling the CDN to remove or stop using a copy before its TTL ends.
- Origin shield is one extra cache tier between all edges and the origin, so the origin sees one request instead of one per edge.
- Request coalescing (also called request collapsing) means: when many requests ask for the same missing object at the same time, send only one request to the origin and make the others wait for it.
- Thundering herd is the problem coalescing solves: an object expires, and thousands of requests all go to the origin at once.
- Anycast is one IP address announced from many places; the network (BGP, the routing system of the internet) sends each packet to the nearest one. GeoDNS is a DNS server that gives different answers to users in different places.
- TLS is the encryption used by HTTPS. TLS termination means the edge, not the origin, decrypts the connection.
- Zipf distribution is a popularity pattern where the object ranked k has a share proportional to 1/k^s. Web traffic often looks roughly like this (an assumption I use, not a law).
1. Requirements
Functional
- Serve static files (images, scripts, video segments, downloads) from a cache near the user.
- On a miss, fetch from the origin, store the copy, and serve it.
- Let the owner control freshness: TTL headers, a purge by URL, by prefix, or by tag.
- Support HTTPS with the customer's own domain and certificate.
- Optional: simple rules (redirect, header edit), compression, and signed URLs for private content.
Non-functional
- Low latency: the edge answers in tens of milliseconds for a hit.
- High hit ratio: the point is to keep traffic away from the origin. Target above 95% for static content (assume).
- Availability: if one PoP or one cache server dies, users still get content from another.
- Origin protection: a cold cache, a purge or a viral object must not flatten the origin.
- Freshness bounds: a purge should take effect everywhere in seconds (assume a target of a few seconds).
- Cost: bandwidth is the biggest bill; every miss costs origin egress.
Out of scope here: live video protocols, DDoS filtering details, and the billing system.
2. Back-of-envelope numbers
Assume a mid-size CDN customer base. Every number below is an assumption.
Peak traffic 100,000 requests/sAverage object size 500 KB (mix of images, scripts, video segments)Edge egress 100,000 x 500 KB x 8 bits = 400 Gbit/sPoPs 200, each with ~20 cache servers -> 4,000 serversPer server (average) 400 Gbit/s / 4,000 = 0.1 Gbit/s (peak is much higher in busy PoPs)Catalogue 1 billion distinct objects x 500 KB = 500 TB at the originDisk per cache server 20 TB SSD -> 4,000 x 20 TB = 80 PB total, but each PoP holds only what its own users ask for (about 20 x 20 TB = 400 TB per PoP)The key arithmetic is the hit ratio. I ran this small program (maths.ts):
hit ratio | origin req/s | origin load vs 90% | avg latency 50.0% | 50000 | 5.00x | 135.0 ms 90.0% | 10000 | 1.00x | 43.0 ms 95.0% | 5000 | 0.50x | 31.5 ms 99.0% | 1000 | 0.10x | 22.3 ms 99.9% | 100 | 0.01x | 20.2 msegress at the edge: 400 Gbit/sorigin egress at 95%: 20 Gbit/s(Assumed: 20 ms for a hit, 250 ms for a miss.) Two lessons from the table:
- Origin load is
R x (1 - h). Going from 90% to 99% does not give "9% better": it gives 10 times less origin load. Think in miss ratio, not hit ratio. - Average latency is
h x Lhit + (1 - h) x Lmiss. At 95% the 5% misses still make the average 31.5 ms against 20 ms for a pure hit. The tail (the slow 1%) matters more than the average, because a page loads many objects.
The system-design-primer's latency table gives the physics: a round trip inside one data centre is about 0.5 ms, and a California to Netherlands round trip is about 150 ms. A CDN turns the 150 ms trip into a trip to a city nearby. (Those are the primer's rounded numbers, not fresh measurements.)
3. API
A CDN has two APIs: the one users hit (plain HTTP, no change for them) and the control API the customer uses.
# Data plane (users): unchanged URLs, only the DNS name points at the CDNGET https://static.example.com/img/cat.jpg?w=200→ 200 OKCache-Control: public, max-age=86400, s-maxage=3600Age: 412 # how long this copy has been in the cache (seconds)X-Cache: HIT # many CDNs add a header like this; the name differs by vendor# Control plane (customer)POST /v1/purge { "urls": ["https://static.example.com/img/cat.jpg"] }POST /v1/purge { "prefix": "https://static.example.com/img/" }POST /v1/purge { "tag": "product-1234" } # surrogate key / cache tagPUT /v1/services/{id} { "origin": "origin.example.com", "defaultTtl": 3600, "shield": "eu-west" }GET /v1/stats?from=... → hit ratio, bytes, origin requests, errorss-maxage is a Cache-Control value that applies to shared caches (a CDN) while max-age applies to browsers too. Using both lets the CDN keep a file longer than browsers do (from memory; I did not re-read RFC 9111).
4. Data model
The edge does not use a relational database. Each cache server keeps:
index (in memory): cacheKeyHash -> { diskOffset, size, etag, lastModified, createdAt, expiresAt, tags[], varyKey }store (on SSD): object bytes + stored response headers at diskOffsetThe control plane keeps ordinary configuration in a replicated database:
service(id, domain, origin_url, default_ttl, shield_region, cert_id, rules_version)purge_request(id, service_id, kind[url|prefix|tag], pattern, created_at, status)certificate(id, domains[], not_after, private_key_ref)Config is pushed to all edges as versioned files and loaded in memory, so a request never calls the control plane.
5. High-level architecture
User in Mumbai Control plane | 1. DNS: static.example.com -> CNAME -> cdn-net.example (an IP near the user) v (config, purge, certs, stats) +-----------+ 2. TLS handshake + HTTP | push config / purge fan-out | Edge PoP |<----------------------------------------------- v | Mumbai | (anycast IP or GeoDNS picked this PoP) [ all edges ] | [LB] [cache][cache][cache] hash(cacheKey) picks one server in the PoP +-----------+ | miss v +-----------+ shared by all edges in a region (fewer places that call the origin) | Shield | one request per object per TTL reaches the origin | Singapore| +-----------+ | miss v +-----------+ | Origin | your servers or object storage +-----------+Request path for one GET:
- The user's resolver asks DNS for the name. GeoDNS or anycast returns an edge near the user.
- The edge ends TLS, builds the cache key, and picks the cache server in the PoP that owns this key (consistent hashing, as in Lesson 1). This way a PoP with 20 servers holds one copy of each object, not 20.
- Fresh copy: send it (hit). Missing or stale: ask the shield. The shield does the same check, and only on a miss calls the origin.
- Each tier coalesces: one fetch per key at a time, others wait.
- The response is stored by every tier it passes (if the headers allow), then sent to the user.
6. Deep dives
6.1 Getting the user to a nearby edge
Two main tools. In real systems they are often combined.
| Method | How it works | Good | Bad |
|---|---|---|---|
| GeoDNS / latency DNS | The CDN's DNS server looks at the resolver's address (or a hint of the client subnet) and returns the IP of a good PoP | Easy to steer: change the answer to drain a PoP | The DNS server sees the resolver, not the user. Caching by resolvers (TTL) slows changes. A resolver far from the user gives a far PoP |
| Anycast | Every PoP announces the same IP by BGP. The internet routing sends packets to the closest announcer (by network distance) | No DNS trick, fast failover: withdraw the route and traffic moves | "Closest" by routing is not always closest by latency. A route change in the middle of a long download can send later packets to another PoP, which breaks a TCP connection (this is why QUIC and short requests help; from memory) |
A simple rule I use: DNS for coarse steering and control, anycast for fast failover and DDoS spread. I did not re-read any vendor's paper on this, so I state it as a common pattern and not as the way one named CDN does it.
Failover: health checks mark a PoP or server bad. With DNS you stop returning it (but old answers live for the DNS TTL, so keep the TTL short, such as 30 to 60 seconds; assumption). With anycast you withdraw the route.
6.2 TLS at the edge
HTTPS must end at the edge, or the edge cannot read the URL to find the cache key. Costs and tricks (from memory, standard practice):
- A TLS handshake needs round trips. If the edge is 10 ms away, the handshake takes tens of ms; if the origin is 200 ms away, hundreds. So the edge is a good place to end TLS. The edge keeps long-lived, reused connections to the origin or shield, so the user's handshake is short and the edge-to-origin leg pays the handshake once.
- Certificates: the customer either uploads one, or the CDN requests one automatically for the customer's domain (e.g., through the ACME protocol used by Let's Encrypt). Many certificates mean the edge needs to choose the right one by SNI (Server Name Indication: the name the client sends in the handshake).
- Private keys are the most sensitive thing at the edge. Keep them in memory or in a hardware module, never on shared disk in plain form.
- Session resumption lets a returning client skip most of the handshake.
If you terminate TLS at the edge, the edge-to-origin leg must be HTTPS too. Plain HTTP between the CDN and the origin exposes user data on the open internet.
6.3 The cache key
The cache key decides what counts as "the same object". Varnish's default hash, from its docs, is the URL plus the Host header (and it does not lowercase either, so Varnish.org/ and varnish.org/ are different entries in theory). Too narrow and users get the wrong content. Too wide and the cache fills with duplicates and the hit ratio falls.
Common rules:
- Lowercase the host. Sort the query parameters. Drop parameters that do not change the response (
utm_*,fbclid). - Add only the request headers that change the response (
Vary). For exampleAccept-Encoding(gzip or brotli) orAccept-Language. Each added header multiplies the number of copies. - Never put cookies in the key for public content. A cookie is nearly unique per user: it makes every request a miss.
This is the builder I ran (key.ts):
const DROP = /^(utm_|fbclid$|gclid$)/;export function cacheKey(rawUrl: string, headers: Record<string, string> = {}, vary: string[] = []): string { const u = new URL(rawUrl); const kept = [...u.searchParams.entries()].filter(([k]) => !DROP.test(k)).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); const q = kept.map(([k, v]) => `${k}=${v}`).join("&"); const v = vary.map((h) => `${h}:${(headers[h] ?? "").toLowerCase()}`).join("|"); return `${u.hostname.toLowerCase()}${u.pathname}${q ? "?" + q : ""}${v ? " #" + v : ""}`;}cdn.example.com/img/cat.jpg?h=100&w=200cdn.example.com/img/cat.jpg?h=100&w=200same key: true <- different order, different case, tracking params: one copycdn.example.com/app.js #accept-encoding:brcdn.example.com/app.js #accept-encoding:gzipsame key: false <- correct: brotli and gzip bytes differHow much does it matter? In the simulation (test E in cdn.ts) I made 30% of requests carry a unique tracking parameter. With the parameter in the key the hit ratio was 46.3% and the origin got 536,658 requests. With the parameter removed from the key it was 73.4% and 265,542 origin requests: half the origin load, from one line of configuration.
6.4 TTL, freshness and revalidation
A copy is fresh until expiresAt = fetchedAt + TTL. The TTL comes from Cache-Control: s-maxage / max-age (or Expires), or from a default the customer sets. After it expires the edge has two choices:
- Fetch again (a full miss). Simple.
- Revalidate: send the origin a conditional request (
If-None-Match: <etag>). If the file has not changed, the origin answers304 Not Modifiedwith no body, which is cheap. The edge resets the TTL.
Useful, widely supported extensions (from memory, not re-read in RFC 5861): stale-while-revalidate lets the cache serve the stale copy at once and refresh it in the background; stale-if-error lets it serve the stale copy when the origin fails. nginx has matching options (proxy_cache_use_stale, proxy_cache_background_update). Both turn an origin outage into "old but working" for users.
TTL choice is a hit-ratio choice. I ran the same traffic with different TTLs (100,000 objects, 1 million requests, cache big enough that size does not limit):
TTL 5 s hit ratio 54.9% origin requests 450917TTL 30 s hit ratio 69.2% origin requests 307967TTL 300 s hit ratio 80.0% origin requests 199941TTL 3600 s hit ratio 80.7% origin requests 192987Short TTLs hurt a lot; after a few minutes the gain flattens out, because the remaining misses are objects that are requested once (the long tail), and no TTL helps them.
Best practice for static assets: put a content hash in the file name (app.3f9a1c.js) and give it max-age=31536000, immutable. The URL changes when the content changes, so you never need to purge it. Reserve purging for things that must keep their URL.
6.5 Invalidation and purge
"There are only two hard things..." the joke goes, and cache invalidation is one of them. The options:
| Method | How | Cost |
|---|---|---|
| Wait for TTL | Do nothing | Free, but users see old content up to TTL |
| Purge by URL | Control plane tells every edge to drop the key | Cheap per URL, but a million URLs is a long job |
| Purge by prefix | Edges scan their index for the prefix | A scan; fine with an in-memory index |
| Purge by tag (surrogate key) | The origin sends Surrogate-Key: product-1234 (the header name differs by vendor). Edges keep a tag -> keys index | The best tool for "a product changed, drop its 50 pages" |
| Soft purge | Mark as stale, not deleted: serve stale while revalidating | No origin spike after a purge |
| Versioned URLs | Change the URL | Zero purge, the right default for assets |
How a purge reaches thousands of servers: the control plane writes the purge to a durable log, and each PoP reads it (or the control plane fans out through a tree: control plane -> regional hubs -> PoPs -> servers). Each server applies it and acknowledges. A new or restarted server must replay the log from its last position, or it could serve a copy that was purged while it was off. The purge status is shown as "complete" only when all PoPs acknowledged, or when a time limit has passed.
A purge is eventually applied. Do not use it for legal takedowns or security without a check that it finished. Also a purge on a hot object makes the next requests all miss at once: that is a thundering herd, the next topic.
6.6 Thundering herd and request coalescing
When a hot object expires (or is purged), every request in the next fraction of a second is a miss. Without protection, each one goes to the origin. The nginx docs describe the fix for proxy_cache_lock: "only one request at a time will be allowed to populate a new cache element ... Other requests of the same cache element will either wait for a response to appear in the cache or the cache lock for this element to be released" with a timeout (default proxy_cache_lock_timeout 5 s; when it runs out, the request goes to the origin but the response is not cached). The Varnish docs say the same idea: it does coalescing automatically.
This is the core of my simulation (an excerpt of cdn.ts). A tier keeps one entry per key with a readyAt time. An entry that exists but is not ready yet is a fetch in progress:
request(key: string, t: number): number { const e = this.cache.get(key); if (e && e.expires > t) { this.cache.delete(key); this.cache.set(key, e); // touch: most recently used goes last if (e.readyAt <= t) { this.hits++; return t; } // fresh and ready: hit if (this.coalesce) { this.coalesced++; return e.readyAt; } // a fetch is running: wait for it } this.fetches++; // miss: ask the parent (shield or origin) const done = this.parent.request(key, t + this.rtt / 2) + this.rtt / 2; if (!(e && e.expires > t && e.readyAt > t)) { this.cache.delete(key); this.cache.set(key, { readyAt: done, expires: done + this.ttl }); } while (this.cache.size > this.capacity) this.cache.delete(this.cache.keys().next().value as string); return done;}(A JavaScript Map remembers insertion order, so the first key is the least recently used. That is the whole LRU.)
Test C: one hot object, 2,000 requests/s for 20 s, TTL 5 s, the origin takes 200 ms:
coalescing OFF requests 40000 origin fetches 1603 coalesced waiters 0 slowest client wait 200 mscoalescing ON requests 40000 origin fetches 4 coalesced waiters 1599 slowest client wait 200 msFour expiries in 20 s means four fetches with coalescing: 400 times fewer origin requests. Without it, each expiry lets about 400 requests through (2,000 per second x 0.2 s while the first fetch is running), which is 1,603 in total. Real herds are worse than this one: more edges, slower origins, and an origin that gets slower under load which makes the herd longer. Note that the client wait is the same (200 ms) in both: coalescing does not slow anyone down.
Related protections: (1) Stale-while-revalidate: waiters get the stale copy at once, so nobody waits. (2) TTL jitter: add a random +/- 10% to the TTL so a million objects fetched together do not expire together. (3) Negative caching: cache 404s and origin errors for a short time (5 to 30 s) so a broken link does not become a stream of origin requests.
6.7 Origin shield
With 200 PoPs and no shield, one popular object is fetched from the origin once per PoP every TTL. A shield tier fixes this: all edges in a region ask the shield first. Test D: 20 edge caches, each holding only 2% of the catalogue; one shield holding 20%:
shield OFF edge hit ratio 56.8% origin requests 432339 (43.2% of all requests)shield ON edge hit ratio 56.8% shield hit ratio 55.0% origin requests 194660 (19.5% of all requests)The edges are the same. The shield turns many small caches into one big one for the misses: origin requests fall from 43.2% to 19.5%, 2.2 times less. The cost is an extra hop on a double miss (edge -> shield -> origin), and the shield is a hot spot, so it needs its own capacity and its own coalescing. Choose the shield location near the origin or in a well-connected region.
6.8 How big must the cache be? (the LRU simulation)
Test A uses 100,000 objects, 1 million requests, a Zipf popularity with exponent s (higher s means the traffic is more concentrated on a few objects), TTL one hour. "Ideal" is a cache that always holds the most popular C objects (no real cache knows the future, so it is an upper bound):
zipf s | cache size | simulated LRU | ideal 'top-C' | origin req 0.8 | 1000 ( 1%) | 20.5% | 34.0% | 795067 0.8 | 5000 ( 5%) | 36.9% | 50.5% | 630719 0.8 | 10000 (10%) | 46.7% | 59.5% | 532823 1.0 | 1000 ( 1%) | 50.7% | 61.9% | 493268 1.0 | 5000 ( 5%) | 66.5% | 75.2% | 335226 1.0 | 10000 (10%) | 73.5% | 81.0% | 264591 1.2 | 1000 ( 1%) | 79.6% | 85.2% | 204467 1.2 | 5000 ( 5%) | 88.5% | 91.9% | 115416 1.2 | 10000 (10%) | 91.4% | 94.3% | 85628(A "hit" here includes a request that waited for a fetch already running.) What I take from it:
- Skew decides everything. The same 1% cache gives 20.5%, 50.7% or 79.6% depending on how concentrated the traffic is. So the first real task is to measure the traffic, not to guess the cache size.
- LRU loses 3 to 14 points to the ideal in this test, because one-off requests push useful entries out. Fixes used in practice: admission rules (do not store an object until it has been requested twice, often done with a Bloom filter that remembers "seen once"), or segmented LRU. I did not test those here.
- Diminishing returns: for
s = 1.0, going from 5% to 10% of the catalogue in the cache adds 7 points, from 1% to 5% adds 16 points. - A mix of tiers helps: small hot objects in memory, big cold objects on SSD.
6.9 Where do 20 servers in a PoP fit?
If a PoP has 20 servers and each request goes to a random one, each server caches the same hot objects, and the PoP's total capacity is one server's capacity. If you instead map hash(cacheKey) to a server (consistent hashing, see Lesson 1), the PoP holds 20 different sets of objects. The trade-off is a very hot object, which hits one server only. Fix: replicate a hot object on a few servers, or put a small in-memory cache in the front-end tier.
7. Failure modes and trade-offs
| Problem | Effect | Handling |
|---|---|---|
| Origin down | Misses fail | Serve stale (stale-if-error), error pages cached briefly, shield serves its stale copy |
| Cold cache (new PoP, restart, big purge) | Origin spike | Shield, coalescing, slow ramp of traffic to a new PoP, soft purge |
| Hot object expires | Herd | Coalescing, stale-while-revalidate, TTL jitter |
| Bad cache key (cookie or random param inside) | Hit ratio drops, origin overload | Alert on hit ratio per service; key normalisation rules |
| Over-wide key | Wrong content to wrong user (private page cached and shown to others) | Do not cache responses with Set-Cookie or Cache-Control: private unless the customer says so; separate paths for private content |
| Purge lost | Stale content stays | Durable purge log, replay on start, status shows progress |
| Whole PoP down | Users move to the next PoP | Anycast withdraw or DNS drain; capacity planned so neighbours can take the extra load (N+1) |
| Origin slow, not down | Edge connections pile up | Timeouts, circuit breaker, limit on concurrent origin fetches per service |
| Certificate expires | HTTPS errors for a customer | Automatic renewal and an alert 30 days before expiry |
| Large file range requests | Every range is a different key | Cache by fixed-size chunks (for example 1 to 4 MB), not whole files |
Trade-offs to say aloud
- Freshness vs hit ratio: a long TTL means higher hit ratio and staler content. Versioned URLs let you have both.
- Push vs pull: pull (fetch on first request) is simple and stores only what is used. Push (upload in advance) suits big releases where you cannot afford a cold start.
- Shield: less origin load, one more hop, one more thing to run.
- Anycast vs DNS: fast failover and simplicity against fine control.
- Purge speed vs cost: faster global purge needs a more complex fan-out.
8. How to say it in an interview (60 seconds)
"A CDN is a set of caches near users. The user reaches the nearest PoP through GeoDNS or anycast; the edge ends TLS, builds a cache key, and uses consistent hashing to pick the server in the PoP that owns the key. A hit is served from there. A miss goes to a regional origin shield, and only on a double miss to the origin. Every tier coalesces requests for the same key, so a hot object that expires costs one origin fetch, not thousands; in my simulation that was 4 fetches instead of 1,603. The cache key must drop tracking parameters; in my test that raised the hit ratio from 46% to 73%. Freshness comes from TTL plus stale-while-revalidate, and I prefer content-hashed URLs so I rarely purge; when I must purge, I use tags and a durable log that every server replays. The number I watch is the miss ratio, because the origin load is request rate times miss ratio: going from 90% to 99% hits means ten times less origin load."
Run it yourself
The three files are cdn.ts (about 55 seconds, it runs 1 million requests many times), maths.ts and key.ts. Run each with node file.ts. They use only Node's standard library and Node 22's built-in type stripping (no constructor parameter properties, so I assign fields by hand).
The simulation is a model. It has one clock, a fixed origin latency, no bandwidth limits, no object sizes, no failures, and a made-up Zipf workload. Use it to see the direction and size of an effect, then measure your own traffic.
Interview questions
How does a CDN make a site faster? (Must know)
It puts copies near the user, so a hit travels a short distance instead of crossing an ocean (the primer's table shows about 150 ms for a California to Netherlands round trip). It also ends TLS close to the user and reuses long connections to the origin, so handshakes are short. And it takes load off the origin: origin requests equal the request rate times the miss ratio.
How does a user get to the nearest edge? (Must know)
Two ways. GeoDNS returns the IP of a good PoP for the user's region, and I can steer by changing answers. Anycast announces one IP from every PoP and BGP routing picks the closest. DNS gives control but is slowed by resolver caches and sees the resolver, not the user. Anycast gives fast failover but "closest" is by routing, not by latency. I would combine them. I say this is a common pattern and not the design of one named CDN.
What is a cache key and what goes wrong with it? (Must know)
The key decides what counts as the same object. Varnish's default is URL plus Host. Too narrow gives wrong content to users; too wide gives duplicates. In my test, keeping a random tracking parameter in the key dropped the hit ratio from 73.4% to 46.3% and doubled origin requests. I normalise the host, sort parameters, drop ones that do not change the response, and add Vary headers only when needed, since each one multiplies the copies. Cookies stay out of the key for public content.
What is the thundering herd and how do you stop it? (Must know)
When a hot object expires or is purged, many requests miss at once and all go to the origin. I use request coalescing: one fetch per key, the rest wait for it. nginx's proxy_cache_lock does exactly this, with a timeout. In my test it cut 1,603 origin fetches to 4 with the same client wait. I add stale-while-revalidate so waiters get the old copy, TTL jitter, and a shield so the herd from many PoPs is also merged.
What is an origin shield and when do you not want one? (Senior signal)
A shield is a cache tier all edges ask before the origin. It turns many small edge caches into one big cache for misses. In my test, origin requests fell from 43.2% to 19.5% of all requests. The cost is an extra hop for a double miss, and another tier to size and run. I would skip it when the origin is already cheap and close, the content is mostly uncacheable, or traffic is so small that the extra hop adds only latency.
How would you invalidate content? (Must know)
First choice: do not. Use content-hashed URLs with a long TTL. When I must purge, I use tags (surrogate keys) so one call drops all the pages that depend on a record. I prefer soft purge (mark stale and revalidate) to avoid an origin spike. The purge goes into a durable log, each server applies it and a restarted server replays it. I tell the team a purge is eventually applied, so it is not a legal-safe delete unless I check completion.
How do you decide the TTL? (Must know)
I split content by how it changes. Hashed assets: one year, immutable. Pages that change on a schedule: minutes, with stale-while-revalidate. Personalised or private responses: not cached, or cached under a key that includes the right dimension. In my test, 5 s gave a 54.9% hit ratio and 300 s gave 80.0%, but 3600 s added only 0.7 more points, so the long tail, not the TTL, set the ceiling.
Why can a cache hit ratio be lower than expected? (How would you debug)
I look at: the cache key (random parameters, cookies, too many Vary), Cache-Control: private/no-store or Set-Cookie blocking storage, a TTL that is too short, a cache that is too small for the long tail (evictions), objects requested only once, range requests creating many keys, and a low-traffic PoP that is mostly cold. I would check the hit ratio per service and per PoP, then sample the keys of misses to see what they have in common.
LRU or something else for the edge? (Senior signal)
LRU is the simple default. In my test it lost 3 to 14 points against the ideal static top-C cache, because one-hit objects push good ones out. Production caches add an admission rule (store on the second request, using a small Bloom filter) or use segmented policies. I would measure on real traffic before changing, because the best policy depends on the traffic shape and the object sizes (big files can evict thousands of small ones).
How does TLS work at the edge? (Senior signal)
The edge holds the certificate and private key, picks the certificate by SNI, and ends the user's TLS connection, which is needed to read the URL for the cache key. The edge-to-origin leg should be HTTPS too, over long-lived reused connections. Keys must be protected (memory or hardware module), and certificate renewal must be automatic with an alert before expiry. I did not re-read any vendor details; this is the standard pattern.
How do you serve a 5 GB video file? (Senior signal)
I do not cache whole files. I split by range into chunks (for example 1 to 4 MB) and cache each chunk with its own key, so a seek in the middle only fetches the chunks it needs, and a partial download is not wasted. For streaming protocols that already use small segment files, each segment is a normal object. I cache the manifest with a short TTL and the segments with a long one.
A big customer deploys a new version and the origin falls over. What happened? (How would you debug)
Likely they purged everything or changed the asset names, so every request missed at once and the cold edges all called the origin. I would check the origin request rate against the purge time, coalescing on the shield, and whether a global purge was used instead of soft purge or per-tag purge. Fixes: soft purge, a ramp (warm popular objects first), a shield with coalescing, a limit on concurrent origin fetches per service, and stale-if-error as a safety net.
Why is hit ratio the wrong thing to quote alone? (Trap)
Because the same hit ratio can hide different problems. Origin load is R x (1 - h): 90% to 99% is ten times less origin load, which a "9% better" description hides. Request hit ratio also differs from byte hit ratio: many small hits can sit beside a few large misses that cause most of the bandwidth. And an average hides the tail. I report miss ratio, byte hit ratio, origin requests per second and p99 latency.
Key takeaways
- A CDN is a tree of caches: edge -> shield -> origin. Users reach the edge by GeoDNS or anycast. The edge ends TLS.
- The cache key is the main hit-ratio control. In my test, a tracking parameter in the key cost 27 points of hit ratio.
- Think in miss ratio: origin load = request rate x (1 - hit ratio). 90% to 99% is 10 times less.
- Coalesce requests at every tier. Measured: 1,603 origin fetches became 4 for one hot object, with no extra wait.
- A shield cut origin requests from 43.2% to 19.5% in the test.
- Prefer content-hashed URLs; purge by tag with a durable log; use soft purge and stale-while-revalidate to avoid spikes.
- All simulation numbers are from made-up traffic. Measure your real traffic.
What's next
Lesson 5: Design Trending Topics and Top-K Requests. A CDN sees billions of requests and wants to know "which objects are hottest right now" without storing a counter for every object. That needs counting with small, approximate memory.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- HTTP Caching: Cache-Control, ETags and the CDN in Between · The Browser Contract
- Cost, Latency and Caching for LLM Features · AI Engineering
- “The UI Is Fast but the API Takes Too Long”: How to Solve It · Scenario Debugging
Part of the study guide
Related series