Skip to the solution
MediumSystem Design13 min read

What is a CDN and how does it work?

454 views

The problem

Question presented to candidate: "We serve images, JavaScript and video to users all over the world from one region, and pages are slow far from it. What is a CDN, how does a request actually get served by one, and what do we have to get right so it helps rather than serving stale content?"

What a strong answer should cover:

  • What it is: a network of edge servers in many PoPs, acting as a caching reverse proxy in front of your origin; users are steered to a nearby edge by anycast or DNS
  • The request path: edge cache hit served locally; cache miss fetched from the origin (often through a tiered cache / origin shield), stored, then served
  • Control: Cache-Control (max-age, s-maxage, stale-while-revalidate), the cache key, and invalidation by TTL, purge, or versioned file names
  • What drives cache hit ratio and origin offload: popularity skew, cache size, number of PoPs, and how much traffic is cacheable at all
  • Beyond static files: request collapsing, TLS termination near the user, DDoS absorption, and edge compute; what not to cache (personalised responses)

Code / implementation expected: No β€” the request flow, headers and trade-offs are the answer; the example simulates hit ratios and the effect of a shield.

  • cdn

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

13 min read Β· 12 sections

Read the code

CDN edge caches: hit ratio, origin shield, request collapsing, TTL vs purge
// A CDN in miniature: edge PoPs with LRU caches, an optional origin shield,
// request collapsing, and what a TTL means for freshness after an update.
// Objects follow a Zipf popularity curve; randomness is a fixed-seed LCG.

let seed = 42;
function rand() { // 32-bit LCG, identical in every language
  seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
  return seed / 4294967296;
}

const OBJECTS = 20000;
const cdf = [];
let sum = 0;
for (let k = 1; k <= OBJECTS; k++) { sum += 1 / k; cdf.push(sum); }
function pick() { // Zipf(s=1): object k is requested with weight 1/k
  const u = rand() * sum;
  let lo = 0, hi = OBJECTS - 1;
  while (lo < hi) { const mid = (lo + hi) >> 1; if (cdf[mid] < u) lo = mid + 1; else hi = mid; }
  return lo;
}

class LRU {
  constructor(cap) { this.cap = cap; this.map = new Map(); }
  get(k) {
    if (!this.map.has(k)) return false;
    this.map.delete(k); this.map.set(k, true); return true;
  }
  put(k) {
    if (this.map.has(k)) this.map.delete(k);
    else if (this.map.size >= this.cap) this.map.delete(this.map.keys().next().value);
    this.map.set(k, true);
  }
}

function simulate(edgeCap, pops, shieldCap) {
  seed = 42;
  const edges = Array.from({ length: pops }, () => new LRU(edgeCap));
  const shield = shieldCap ? new LRU(shieldCap) : null;
  let edgeHits = 0, origin = 0;
  const N = 200000;
  for (let i = 0; i < N; i++) {
    const obj = pick();
    const edge = edges[i % pops]; // users in different regions reach different PoPs
    if (edge.get(obj)) { edgeHits++; continue; }
    if (shield && shield.get(obj)) { edge.put(obj); continue; }
    origin++;
    if (shield) shield.put(obj);
    edge.put(obj);
  }
  return { edgeHit: (100 * edgeHits) / N, origin, offload: (100 * (N - origin)) / N };
}

console.log("Part 1: 200000 requests, 20000 objects (Zipf), spread over PoPs");
console.log("  pops  edge cache  shield   edge hit   origin fetches   offload");
for (const [pops, cap, sh] of [[1, 1000, 0], [1, 4000, 0], [8, 1000, 0], [8, 4000, 0], [8, 1000, 8000], [8, 4000, 8000]]) {
  const r = simulate(cap, pops, sh);
  console.log(`  ${String(pops).padStart(4)}  ${String(cap).padStart(10)}  ${String(sh || "-").padStart(6)}  ${r.edgeHit.toFixed(1).padStart(8)}%  ${String(r.origin).padStart(15)}  ${r.offload.toFixed(1).padStart(7)}%`);
}

console.log("\nPart 2: a new video goes live, 500 requests reach one PoP over 1000 ms");
const fetchMs = 200; // the origin takes 200 ms to answer the first miss
let naive = 0, collapsed = 0, waited = 0, hits = 0;
for (let i = 0; i < 500; i++) {
  const t = Math.floor((i * 1000) / 500); // arrival time in ms
  if (t >= fetchMs) { hits++; continue; } // the object is cached once the first fetch returns
  naive++; // without collapsing, every request during the fetch is another miss
  if (i === 0) collapsed++; else waited++; // with collapsing they wait for the one fetch
}
console.log(`  arrivals during the first fetch: ${naive}, arrivals after it: ${hits}`);
console.log(`  without request collapsing: ${naive} origin fetches`);
console.log(`  with request collapsing:    ${collapsed} origin fetch, ${waited} requests wait for it`);

console.log("\nPart 3: object changes at t=100s, cached with max-age=300 at t=0s");
const timeline = [50, 150, 299, 301];
for (const t of timeline) {
  const ttlServe = t < 300 ? "old (cached copy still fresh)" : "new (expired, refetched)";
  const purgeServe = t < 100 ? "old" : "new (purged at t=100s)";
  console.log(`  t=${String(t).padStart(3)}s  TTL only: ${ttlServe.padEnd(30)} TTL + purge: ${purgeServe}`);
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 37 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

What is the difference between optimistic and pessimistic locking?

Continue