Engineering Atlas › System Design Interviews

Design a content delivery network

20 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope

Back-of-envelope

These are round planning numbers for a mid-sized commercial CDN, not any vendor's published figures.

API

# Control plane (customer-facing, authenticated)
PUT /v1/distributions/{dist_id}
  { "hostnames": ["static.shop.example"],
    "origins": [{"host": "origin.shop.example", "weight": 100}],
    "cache_key": {"query_params": ["v", "size"], "headers": ["accept-encoding"]},
    "default_ttl_s": 3600, "serve_stale_if_error_s": 86400 }
  -> 200 { "version": 418, "status": "propagating" }

POST /v1/distributions/{dist_id}/purge
  { "type": "tag", "values": ["product-4411"], "mode": "soft" }
  -> 202 { "purge_id": "pg_9a1c", "status": "propagating" }

GET /v1/purges/{purge_id}
  -> 200 { "status": "complete", "pops_acked": 250, "pops_total": 250 }

# Data plane (end users)
GET https://static.shop.example/img/hero.jpg?v=12
  Range: bytes=0-1048575
  -> 206  Content-Range: bytes 0-1048575/8388608
          Cache-Status: shield-fra; fwd=uri-miss; stored, edge-fra3; hit
          Age: 532

The data plane is ordinary HTTP under RFC 9110 and RFC 9111. Cache-Status follows RFC 9211, which lists caches from the one nearest the origin to the one nearest the user, so the shield's entry (a miss it stored earlier) comes before the edge's hit (RFC 9211). The control plane is asynchronous on purpose: "applied everywhere" is a distributed acknowledgement the caller polls for, not something one request can promise.

Data model

High-level design

flowchart LR
  U["User"] --> AC["Anycast IP"]
  AC --> L4["PoP L4 balancer"]
  L4 --> FE["Edge server (TLS)"]
  FE --> OW["Owner server for key"]
  OW --> SH["Regional shield"]
  SH --> O["Customer origin"]
  API["Control API"] --> PG["Config DB"]
  API --> PL["Purge log"]
  PG --> CD["Config distributor"]
  CD --> FE
  PL --> FE
  FE --> LG["Log shipper"]
  LG --> CH["Analytics store"]

Read path. DNS returns an anycast address and BGP delivers packets to a nearby PoP, where a layer 4 balancer spreads connections across servers. The receiving server terminates TLS, maps the hostname to a distribution, computes the cache key and hashes it onto the PoP's ring to find the owner server, forwarding to it if needed (about 100 µs inside the PoP). On a miss, the owner collapses concurrent requests for the same key into one fetch from the regional shield. The shield runs the same logic and fetches from the origin only on its own miss. Each layer stores the response as it streams back, if the headers allow.

Write path. A config change becomes a new version in Postgres. The distributor compiles a snapshot and pushes it in stages (canary PoPs, then 10%, then everyone), and servers swap snapshots atomically between requests. A purge is appended to the purge log; servers apply entries in seq order and report their applied seq.

Deep dives

1. Getting users to the right PoP, and to the right server inside it

DNS steering answers each resolver with a PoP chosen by geography, measured latency and load. It gives fine control, but it routes by the resolver's location rather than the user's, and every change waits for DNS TTLs. Anycast announces the same prefix from every PoP and lets BGP pick, so a PoP that withdraws its routes stops receiving new connections within seconds, and a volumetric attack spreads across all PoPs instead of landing on one. Its weakness is that "nearest" means best BGP path, not lowest latency, and a route change mid-connection can land packets on a PoP with no state for them. I'd use anycast by default and DNS steering for coarse shifts such as draining a region. The Anycast Routing lesson covers the BGP mechanics.

Inside the PoP, the L4 balancer uses ECMP from the routers plus a consistent-hash table (in the style of Maglev, or Cloudflare's Unimog) so a server added or removed moves few connections. The more interesting choice is the second hash, from cache key to owner server. If every server cached everything it served, a 12-server PoP would hold only one server's worth of distinct content. Hashing keys to owners makes the PoP one large cache. The cost is an internal hop and hot keys: a viral object would pin one server. So each server also keeps a small local cache of keys it forwards above, say, 1,000 requests per second.

2. The cache hierarchy, collapsing and admission

Tiers. Each PoP is assigned to one shield PoP in its region, so a miss goes edge, then shield, then origin. With 250 edge PoPs and 12 shields, a cold object costs at most 12 origin fetches instead of 250, and the shields' large disks hold the long tail that small PoPs evict. A customer with one origin can pick a single shield next to it for maximum offload. Cloudflare's Tiered Cache offers both shapes: a latency-chosen single upper tier per origin, or regional upper-tier pools chosen by hashing the cache key (Cloudflare docs).

Collapsing. When a popular object expires, hundreds of requests can miss in the same millisecond. The owner keeps an in-flight table keyed by cache key: the first miss fetches, and later ones wait on that fetch and stream the same bytes as they arrive. If the response turns out to be uncacheable (private, or a Set-Cookie), it must not be shared: the waiters fetch on their own and the key is marked "do not collapse" for a while. The Cache Stampede lesson covers the general pattern.

Admission and eviction. Plain LRU on SSD lets one-hit wonders, such as a crawler walking a catalogue, push out objects that are requested every hour. Two cheap defences work well: admit an object to SSD only on its second request within a window (tracked with a small counting filter), and split the cache into probation and protected segments so a newcomer can't evict anything that has proven itself. Akamai found that 74% of about 400 million cached objects on a typical cluster were requested only once in two days, and cache-on-second-hit with two rotating Bloom filters cut disk writes by 44% while raising byte hit rate (Maggs and Sitaraman, CCR 2015). Facebook's photo caching study found that a segmented LRU (S4LRU) raised the edge object-hit ratio by 8.5% over FIFO, cutting downstream requests by 20.8% (Huang et al., SOSP 2013).

Large objects. A 4 GB software download or a long video file is not cached as one unit. The edge fetches it in fixed slices (for example 1 MB, using range requests to the origin) and stores each slice as its own cache entry, the approach nginx ships as its slice module (nginx docs). A seek costs one slice fetch, and eviction can drop a file's cold end while keeping its popular start. The danger is stitching slices from two versions, so every slice records the ETag and a mismatch discards them. The practice builds exactly this.

3. Purging worldwide in five seconds

URL purges are easy: hash the key and delete the index entry. Tag and prefix purges are the hard part. "Purge everything tagged product-4411" might match 3 objects or 3 million, spread across 250 PoPs and many servers, and scanning indexes to find them misses the deadline.

Tag generations. Each server keeps a counter per (distribution, tag). When an object is stored, its index entry records the current generation of each of its tags. A tag purge simply increments the counter. On lookup, the server compares the entry's recorded generations with the current ones, and a mismatch means the entry is treated as purged (hard mode) or stale and must be revalidated (soft mode). A purge therefore costs one tiny message and one hash-table write per server, no matter how many objects it covers. Dead entries are reclaimed by normal eviction. Prefix purges use a generation per configured prefix, and "purge all" is a generation on the distribution.

Delivery. Each PoP streams the replicated purge log from a nearby relay and resumes from its last applied seq after a disconnect, so no purge is lost. Meeting 5 seconds is mostly about not queuing: keep purges off the log and config channels, and alert when any PoP's applied seq lags.

Soft purge as the default. A hard purge of a popular tag empties caches worldwide at once and stampedes the origin. Marking objects stale lets the edge serve the old copy while one collapsed revalidation per PoP runs. Fastly works this way: a soft purge marks content stale and a hard purge makes it unusable at once, and a purge-all can never be soft, so Fastly suggests tagging every object with a shared surrogate key to get a soft purge-all (Fastly docs).

Down the stack

Layer 1The contract

A cache hit returns bytes that were valid under RFC 9111 at the time they are served: within their freshness lifetime, or within an explicit stale-while-revalidate or stale-if-error window (RFC 5861). A response marked no-store or private is never shared between users. A request whose key matches a purge with sequence number s is never served a copy stored before s once that PoP has acknowledged s, and the target is that every PoP acknowledges within 5 seconds at p99. Range responses are always assembled from a single version of the object, identified by its ETag.

Layer 2The mechanism

Each cache server keeps the index in RAM as a hash table keyed by a 128-bit hash of the cache key. Bodies are written to SSD in large append-only segments (tens of MB), and each index entry stores its segment and offset. Eviction works per segment, which keeps SSD writes sequential and spares the flash. The in-flight table for collapsing maps a key to a list of waiting requests and a growing buffer that waiters read from. A server persists its applied purge seq, so it resumes in order after a restart.

Layer 3The machine

A RAM hit costs a hash lookup (about 100 ns) and a copy into the TLS record layer. An SSD hit adds an NVMe read of roughly 80 to 100 µs for the first 4 KB block. The expensive part is encryption: AES-GCM with AES-NI runs at a few GB/s per core, so a server pushing 40 Gbit/s (5 GB/s) spends several cores on crypto alone. Kernel TLS (kTLS) lets sendfile move bytes from the page cache to the socket without a user-space copy, and some NICs offload the encryption. On the network side, a new TCP connection starts with a congestion window of 10 segments, about 14 KB (RFC 6928), so a 150 KB response needs several round trips to reach full speed. This is why a PoP 10 ms away beats an origin 120 ms away by far more than the 110 ms difference for the first request. TLS 1.3 needs one round trip for the handshake (RFC 8446), and resumption or QUIC 0-RTT removes it for returning users.

Why it's built this way

  1. Light in fibre covers about 200 km per millisecond, and TCP and TLS need several round trips per connection, so serving from close to the user is the only way to cut latency once the server is fast.
  2. Close to the user means hundreds of small sites with limited disk, so each PoP must act as one cache (key hashing) and lean on a shield tier for the long tail.
  3. Many users ask for the same object at the same moment, so misses must be collapsed or the origin sees a multiple of the edge's load.
  4. Caches make it hard to change answers, so purge and config delivery are first-class write paths with ordering, acknowledgements and deadlines.
  5. Every config change reaches every server, so a config push carries the blast radius of a global deploy and needs the same staging.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Anycast routingFast failover, spreads DDoS, no TTL waitBGP path is not lowest latency; route shifts can break long connectionsDefault for a global CDN
DNS steeringFine-grained, load-aware controlResolver location, TTL delaysDraining regions, dedicated customer pools
Key hashing inside a PoPPoP behaves as one large cacheExtra internal hop; hot keys need replicationPoPs with more than a few servers
Shield tierLarge origin offload; holds long tailExtra hop on misses; shield becomes criticalMost customers; mandatory for weak origins
Hard purgeContent gone at onceSynchronised refill stampedes originLegal takedowns, leaked data
Soft purge via generationsO(1) per purge; origin protectedBriefly serves stale content during revalidationDefault for content updates

Leadership lens

Check yourself

  1. Why do tag purges use per-tag generation counters instead of scanning the cache index for matching objects?

    Show answer

    A tag can match 3 objects or 3 million across 250 PoPs, and scanning indexes misses the 5-second deadline. Incrementing a (distribution, tag) counter costs one small message and one hash-table write per server; entries whose recorded generation no longer matches are treated as purged or stale, and dead entries are reclaimed by normal eviction.

  2. A customer hard-purges a popular tag. What goes wrong, and why is soft purge the default?

    Show answer

    Caches worldwide empty at once and the synchronized refill stampedes the origin. Soft purge marks objects stale, so the edge keeps serving the old copy while one collapsed revalidation per PoP runs, at the cost of briefly serving stale content.

  3. Why hash cache keys to an owner server inside a PoP instead of letting each server cache what it serves, and what is the cost?

    Show answer

    If every server cached everything it served, a 12-server PoP would hold only one server's worth of distinct content; key hashing makes the PoP one large cache. The cost is an extra internal hop and hot keys pinning one server, handled by a small local cache for keys forwarded above about 1,000 requests per second.

  4. With 250 edge PoPs and 12 shields, what does a cold object cost the origin, and why does the per-server cache index live in RAM?

    Show answer

    At most 12 origin fetches instead of 250, because each PoP misses to its regional shield. The index is about 100 to 150 bytes per entry, so 30 million objects per server is only 3 to 5 GB of RAM, while bodies stay on SSD.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →