Skip to the notes
JSGroundwork
JSGroundwork handwritten · web dev
✎Playground→⌘Problems↻Review🔥Progress

Chapters

24 chapters
⌕
Beginner7›
B1The mental modelB2Client-server basicsB3Vertical vs horizontalB4Databases in designB5Caching fundamentalsB6APIs & communicationB7Walkthrough: URL shortener
Intermediate9›
I1Load balancing in depthI2CDNsI3Message queues & asyncI4Consistency modelsI5Rate limiting & throttlingI6Designing for availabilityI7Storage systemsI8Search systemsI9Walkthrough: feed/chat
Advanced8›
A1CAP theorem in depthA2Sharding at scaleA3Distributed consensusA4Fault toleranceA5Observability at scaleA6Capacity estimationA7Case studiesA8Tradeoff thinking
/ search[ ] chaptert top

System Design levels

1Beginner2Intermediate3Advanced

Ready to read

JSJavaScript⑂Git◎Interview prepΣDSA in JSSDSystem Design
More topics15›
</>HTML{ }CSS⚛ReactNNext.jsNeNest.jsTSTypeScriptNoNode.js🐳DockerDBSQL & Databases✓Testing🔒Web Security☁Cloud & DevOps◈GraphQL◆Redis☸Kubernetes
100%
I2

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.

client edge PoP Singapore origin us-east-1 8 ms RTT only on MISS +160 ms RTT HIT: ~12 ms to first byte — TLS terminated locally, warm connection MISS: ~190 ms — and 3 round trips if the origin link is cold at 95% hit rate the weighted average is ~21 ms, and origin sees 1/20th the traffic
The interesting number is not the hit latency, it's the hit rate — everything in this chapter is a lever on that one percentage.

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.

Say it like this → "The edge terminates TLS ~8 ms from the user instead of 180 ms away. Even for a full miss that's a win, because the handshake round trips happen locally and the edge keeps a warm, tuned connection pool to origin — I'm paying one long RTT instead of three."

Push vs pull: who decides what lives at the edge

 Pull (origin-pull)Push
Who populatesFirst user to request an object; edge fetches and stores itYou upload objects to the CDN ahead of time, usually in CI
Cost of a cold objectOne user eats the full origin RTT; N PoPs means up to N cold fetches unless there's a shield tierZero — it's already there
Storage costOnly what's actually requestedEverything, in every region, whether requested or not
Operational loadNear zero — set headers and point DNS at itA deploy step that can fail, plus lifecycle management
Reach for this whenAlmost always. It's self-tuning: popular objects are cached, the long tail isn'tLarge, 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

DirectiveWhat it actually doesThe part people get wrong
max-age=NFresh for N seconds in any cache, including the browserOnce sent, you cannot recall it from a browser. Deploying a bad 1-year max-age is unfixable without changing the URL
s-maxage=NSame, but only for shared caches (CDN, proxy). Overrides max-age thereThe lever you want: max-age=0, s-maxage=600 means browsers always revalidate but the CDN absorbs the load
publicCacheable by shared caches even when the request had an Authorization headerSetting this on an authenticated endpoint is exactly how personalised pages leak
privateBrowser may cache; shared caches must notIt is not a security control — it's a hint. Don't rely on it for secrets
no-cacheStore it, but revalidate with the origin before every reuseDoes not mean "don't cache". That's no-store. This is the single most common misreading in the whole spec
no-storeNever write it to disk or memory anywhereCorrect for account pages and API responses with PII; wasteful everywhere else
must-revalidateOnce stale, you may not serve it — even if the origin is downTurns an origin outage into a user-visible outage. Usually you want the opposite
stale-while-revalidate=NFor N seconds past expiry, serve the stale copy immediately and refresh in the backgroundThe highest-value directive in the table and the most under-used — it decouples freshness from latency
stale-if-error=NIf the origin returns 5xx or times out, keep serving the stale copy for N secondsFree availability. Your CDN becomes a static failover for the whole site
immutableDon't even conditionally revalidate on a user reloadOnly safe with content-hashed filenames. Pair with max-age=31536000
Vary: HAdds request header H to the cache keyVary: User-Agent shatters your hit rate into thousands of fragments. Vary: Accept-Encoding is fine (3 values)
The three header sets worth memorising
  • 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.
⚠ ETags computed per-server break behind a load balancer If your ETag is derived from the file's inode or mtime (nginx's default is 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"

ContentTTLReasoning
Hashed JS/CSS/fonts1 year, immutableThe URL changes when the bytes change, so staleness is impossible by construction
Un-hashed images, PDFs1-7 days at the CDN, minutes in the browserYou can purge the CDN in seconds; you cannot purge browsers
Marketing / docs HTMLs-maxage=300 + swr=86400Editors expect changes within minutes, not instantly; SWR means nobody ever waits for the refresh
Product listing / search resultss-maxage=10-60 + swr60 s of staleness on a catalogue is invisible to users and removes 99% of origin reads on a hot query
Price, inventory counts-maxage=0-5, or don't cacheWrong price is a business incident. Cache the page shell, fetch the number client-side
Anything per-userno-store at the CDNCache 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.

shop.example /products/list ?page=2&sort=price Vary: Accept- Encoding cache key = safe: nothing here identifies a person, so every visitor shares one entry GET /account/orders Cookie: session=alice… Set-Cookie in response cookie NOT in the key, response marked public Bob requests the same path, hits Alice's cached entry, and reads her order history. One request poisons the entry for every user in that PoP until the TTL expires.
The green row is a good key: coarse enough to be shared, precise enough to be correct. The red row is the same bug that has taken down Steam, several banks, and at least one airline.
⚠ The classic: caching a personalised response It needs three things to line up, and they line up depressingly often: a response that varies by user, a cache key that ignores the thing identifying that user (cookie, Authorization header), and a 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=2 and ?page=2&sort=price must hash the same, and tracking params like utm_source, fbclid, gclid must 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|bot at 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.

MechanismPropagationReach for this when
Versioned / hashed URLInstant, by constructionDefault for every build artefact — JS, CSS, images, fonts
Purge single URLSeconds globallyOne asset was published wrong; a legal takedown
Surrogate-key / cache-tag purge~150 ms - a few secondsThe 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 everythingSeconds, then a stampedeAlmost 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
Say it like this → "I'd tag responses with surrogate keys for the entities they render, and purge by tag on write. That gives me long TTLs — minutes, not seconds — with event-driven freshness, so the hit rate stays above 95% and edits still show up immediately. Full purges I'd treat as an incident tool, because they turn the CDN into an origin stampede."

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

WorkloadEffectWhy
Static assets, images, video, downloadsEnormous95-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)LargeOne 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 TTLsThe traffic is spiky and repetitive — exactly what a cache is for
Highly personalised API (/me/feed)~Nothing on cacheabilityKey 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 definitionPOST/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 hurtAny 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-store and 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.
←previousLoad balancing in depth↑ CovernextMessage queues & async→