Skip to content
Commitwise
Menu
System Design

CDNs

Serve images, video, app bundles and even API responses from servers close to users with a content delivery network, control what's cached and for how long with Cache-Control, design cache keys that hit, ship changes with versioned URLs instead of purges, and protect private files with signed URLs.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The servers are in Frankfurt. A user in Sydney opens the app, and every product image travels 16,000 km: about 300 ms per round trip before a single byte arrives, times dozens of images. Meanwhile, the Frankfurt servers spend most of their bandwidth sending the same 50 popular images to millions of people.

A CDN (content delivery network) puts copies of that content on servers in hundreds of cities. The Sydney user gets images from Sydney, the origin servers send each image once per region instead of once per user, and a traffic spike hits the CDN instead of your database.

New to some of these? Open what you need, or skip ahead.

Basics:What are clients and servers?

A server is a computer (usually many of them) that runs your backend: it stores data and answers requests. A client is whatever sends the requests: your mobile app, a website, another server.

Basics:What is HTTP caching?

HTTP responses can carry headers that say whether and for how long a copy may be reused, for example Cache-Control: max-age=3600 (“reuse this for an hour”). Browsers, mobile HTTP clients and CDNs all read these headers. The Caching lesson covers caching in general.

Part Not started

Copies close to every user

A bestselling book isn’t shipped from the publisher’s warehouse every time someone orders it. Local bookshops keep copies on their shelves; when they run out, they order more from the publisher. Readers get the book faster, and the publisher’s warehouse isn’t overwhelmed.

A CDN is a chain of local bookshops for your files. Its servers, called edges (or points of presence, PoPs), sit in hundreds of cities. Your own servers are the origin, the publisher. When a user asks for an image:

  • Cache hit: the nearest edge already has it, and answers in a few milliseconds.
  • Cache miss: the edge asks your origin, stores the response, and answers. The next user nearby gets a hit.

Requests reach the nearest PoP via anycast or DNS-based routing. Each PoP caches by cache key (by default scheme, host, path and query string) according to the origin’s Cache-Control (s-maxage for shared caches, max-age, private, no-store) and its own rules. Misses go to the origin, often through a mid-tier or origin shield that collapses misses from all PoPs into one origin request per object. Beyond static files, CDNs terminate TLS close to users (saving handshake round trips), absorb DDoS traffic, run edge functions, transform images, and can cache API responses that are public or keyed safely.

A request through a CDN

HTTPSmissmissUser in SydneyEdge: SydneycacheOrigin shieldone regionOriginFrankfurt

Tap a box to see what it does.

# A product image with a content hash in its name: never changes, cache for a year
GET /img/lamp.3f9a2c1e.webp
Cache-Control: public, max-age=31536000, immutable

# The public product list: browsers 60 s, the CDN 5 minutes, serve stale while refreshing
GET /api/products?category=lamps
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60

# The user's own profile: may be cached by the user's device only, never by the CDN
GET /api/me
Cache-Control: private, max-age=0, must-revalidate

# A payment response: don't store anywhere
POST /api/payments
Cache-Control: no-store
  • public means shared caches like CDNs may store it; private means only the user’s own device may.
  • max-age is how many seconds a copy may be used; s-maxage overrides it for shared caches (the CDN), so the CDN can keep things longer than phones do.
  • immutable with a long max-age is for files whose name changes when their content changes: they never need checking again.
  • no-store means don’t keep a copy at all.
Spot the bugEveryone is logged in as the same person

Tap the line with the bug.

@GetMapping("/api/me")
fun me(@AuthenticationPrincipal jwt: Jwt): ResponseEntity<ProfileDto> =
    ResponseEntity.ok()
        .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
        .body(profiles.load(jwt.subject))
@GetMapping("/api/me")
ResponseEntity<ProfileDto> me(@AuthenticationPrincipal Jwt jwt) {
    return ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
            .body(profiles.load(jwt.getSubject()));
}
app.get("/api/me", requireAuth, async (req, res) => {
  res.set("Cache-Control", "public, max-age=300");
  res.json(await profiles.load(req.user.id));
});

  • Anycast/DNS to the nearest PoP; cache by key per Cache-Control (s-maxage for shared caches, private, no-store); origin shield collapses misses.
  • Also: TLS termination near users, DDoS absorption, image transforms, edge functions.
  • Authenticated or personal responses: private/no-store; CDN rules bypass caching on Authorization/session cookies by default.

Check this part

  1. What happens on a CDN cache miss?

  2. Which header keeps a personal response out of the CDN?

  3. What does an origin shield do?

A CDN has edge servers in many cities. A request goes to the nearest edge; on a cache hit it answers immediately, on a miss it fetches from your origin, stores the response and answers. Cache-Control headers from the origin decide what's stored and for how long.

Part Not started

Cache keys and shipping changes

Two questions decide whether a CDN helps or hurts:

  • How do users get the new version after you change something? If the CDN keeps the old file for a year, a bug fix won’t reach anyone.
  • How many different cache keys does the same content end up under? If every request looks different to the CDN, nothing is ever a hit.

The standard answer to updates is versioned (fingerprinted) URLs: build tools put a content hash in file names (app.8d2f1c.js), the HTML references the new name, and old and new files coexist with year-long, immutable caching. Only the small entry points (HTML, manifests, index.json) get short TTLs or revalidation. Purges are for emergencies (a leaked file, a legal takedown), are eventually consistent across PoPs, and cause a miss storm. Hit rate depends on key cardinality: normalize or strip irrelevant query parameters (UTM tags, cache busters), sort parameters, avoid Vary: User-Agent or Vary: Cookie, and bucket variants explicitly (device class, image width breakpoints, language).

# HTML: always checked, so it can point to new asset names right after a deploy
GET /index.html
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300

# Assets referenced by the HTML: new content = new name, so they're cached forever
GET /assets/app.8d2f1c.js
Cache-Control: public, max-age=31536000, immutable

GET /assets/app.b71e09.css
Cache-Control: public, max-age=31536000, immutable
  • After a deploy, the HTML (cached for at most a minute at the CDN) references app.9e44a0.js, a new URL the CDN has never seen, so it fetches the new file. No purge needed.
  • Old pages still open in someone’s browser keep loading app.8d2f1c.js, which still exists: nothing breaks mid-session.
  • For mobile apps, the same idea applies to images and downloadable content: include a version or hash in the URL (/avatars/42.v7.webp) instead of reusing /avatars/42.webp.

Predict the output

A website serves /app.js (no hash in the name) with Cache-Control: public, max-age=31536000. A critical bug fix is deployed. When do users get the fix?

Production puzzles

Spot the bugA 3% hit rate

Tap the line with the bug.

CDN cache key: full URL including query string
Vary: User-Agent, Accept-Language, Cookie

Requests for the same banner image:
  /img/banner.webp?utm_source=newsletter&utm_campaign=nov
  /img/banner.webp?utm_source=instagram&fbclid=IwAR3x...
  /img/banner.webp?w=800&t=1731162011
  /img/banner.webp?t=1731162012&w=800

Caching API responses

Public, read-heavy endpoints (catalogs, configuration, feature flags, search suggestions) can be cached at the edge for seconds with s-maxage and stale-while-revalidate: a 5-second TTL on an endpoint with 20,000 requests per second turns it into a few origin requests per second per PoP. Keep personalization out of those responses (fetch per-user data separately), and use surrogate keys (Surrogate-Key: product-42) where the CDN supports them, so a product update can purge exactly the responses that include it.

  • Fingerprinted assets: max-age=31536000, immutable; entry points (HTML, manifests) short or revalidated; no purges for deploys.
  • Purges: emergencies only; eventually consistent; cause miss storms (shield helps).
  • Key hygiene: whitelist and sort query parameters, no Vary: Cookie/User-Agent on shared content, normalize variants into buckets; monitor hit ratio per path.
  • Edge-cache public API responses for seconds with stale-while-revalidate; surrogate keys for targeted purges.

Check this part

  1. How do you ship a new version of app.js through a CDN without purging?

  2. Why does Vary: Cookie on a static image ruin the hit rate?

Version files by content hash in their URL, cache them forever and never purge; cache HTML and API responses briefly with stale-while-revalidate. Keep cache keys small: drop tracking parameters and avoid Vary on headers with many values, or the hit rate collapses.

Part Not started

Private files and images

Not everything is public. A purchased e-book, a user’s uploaded ID document or a paid video course must only reach people allowed to see them, yet they’re exactly the large files a CDN is best at delivering.

The usual solution is a signed URL: your backend checks that the user may see the file, then creates a link with a signature and an expiry time, like a concert ticket with a date on it. The CDN checks the signature and the time itself, without asking your backend, and refuses links that were changed or have expired.

Signed URLs (or signed cookies for many files, such as an HLS video’s segments) carry an expiry and a signature over the path and policy, verified at the edge with a key the origin shares with the CDN (CloudFront key groups, Cloud CDN signed URLs, S3 presigned URLs for direct storage access). Keep lifetimes short (minutes), scope them to one object or prefix, never put them in long-lived caches or logs, and keep the cache key without the signature parameters so the object is still cached once. For images, edge transformation (resize, format negotiation to AVIF/WebP, quality) with a small set of width breakpoints cuts bytes dramatically while keeping cache keys bounded.

@GetMapping("/api/receipts/{id}/download")
fun download(@PathVariable id: String, @AuthenticationPrincipal jwt: Jwt): ResponseEntity<Void> {
    val receipt = receipts.findByIdAndOwner(id, jwt.subject) ?: return ResponseEntity.notFound().build()
    val url = cdnSigner.sign(
        path = "/private/receipts/${receipt.fileKey}.pdf",
        expiresAt = Instant.now().plus(Duration.ofMinutes(5)),
    )
    return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).build()
}
@GetMapping("/api/receipts/{id}/download")
ResponseEntity<Void> download(@PathVariable String id, @AuthenticationPrincipal Jwt jwt) {
    return receipts.findByIdAndOwner(id, jwt.getSubject())
            .map(r -> cdnSigner.sign("/private/receipts/" + r.fileKey() + ".pdf",
                                     Instant.now().plus(Duration.ofMinutes(5))))
            .map(url -> ResponseEntity.status(HttpStatus.FOUND).location(URI.create(url)).<Void>build())
            .orElse(ResponseEntity.notFound().build());
}
app.get("/api/receipts/:id/download", requireAuth, async (req, res) => {
  const receipt = await receipts.findByIdAndOwner(req.params.id, req.user.id);
  if (!receipt) return res.sendStatus(404);
  const url = cdnSigner.sign(`/private/receipts/${receipt.fileKey}.pdf`, {
    expiresAt: Date.now() + 5 * 60_000,
  });
  res.redirect(302, url);
});
  • The backend decides who may see the file (here, only its owner) and issues a link valid for 5 minutes.
  • The CDN delivers the file from a nearby edge, checking the signature itself.
  • A link shared on social media stops working after 5 minutes; changing the path in the link breaks the signature.
  • The file name is a random key (fileKey), not the receipt number, so URLs can’t be guessed.
Exercisemedium

Images for a shopping app

A shopping app shows product images in a grid (about 180 points wide), on the product page (full screen width) and zoomed (up to 2,000 pixels). Originals are 4,000-pixel JPEGs of 3 MB. Design the image URLs and caching so phones download as little as possible and the CDN keeps a high hit rate.

›

Don’t serve the original. Offer a few fixed widths rather than any width, choose the format by what the device supports, and think about what happens when a product photo is replaced.

SolutionTry it yourself first
  • A few fixed widths: /img/{imageId}.{version}/w{width}.{format} with widths 200, 400, 800, 1200 and 2000. The app picks the smallest width at least as large as the view’s size times the screen scale (a 180-point cell at 3× needs 540 px, so w800). Five sizes per image keep the cache key space small; “any width” would create thousands of variants.
  • Modern formats: request webp or avif (both platforms support WebP; AVIF on recent versions), often 30 to 50% smaller than JPEG.
  • Resize at the edge (the CDN’s image service) or once at upload; the 3 MB original is never sent to phones.
  • Versioned and immutable: the version changes when the photo is replaced, so all sizes are Cache-Control: public, max-age=31536000, immutable, and the product API returns the current version.
  • On the device: an image library with memory and disk caches (Coil, Kingfisher, Nuke) avoids downloading again on scroll.

A grid image is then around 30 to 60 KB instead of 3 MB, served from a nearby edge.

Notes on signed delivery

  • Cache key without the signature: configure the CDN to exclude Signature, Expires and Key-Pair-Id from the key, or every signed link becomes its own cache entry.
  • Streaming video: HLS/DASH playlists reference many segment files; use signed cookies or a token on a path prefix rather than signing each segment.
  • Revocation: you can’t revoke a signed URL before it expires, which is why lifetimes are minutes. For instant revocation, put a token check in an edge function against a small denylist.
  • Direct uploads use the same mechanism in reverse: the backend issues a presigned upload URL, and the client uploads straight to object storage without the file passing through your servers.
  • Signed URLs/cookies: short expiry, scoped path, verified at the edge; signature params excluded from the cache key; can’t be revoked early (edge-function denylist if needed).
  • Signed cookies or prefix tokens for HLS/DASH segment sets; presigned uploads straight to object storage.
  • Edge image transforms with fixed width breakpoints and format negotiation; versioned, immutable image URLs.

Check this part

  1. How can a CDN deliver a private receipt only to its owner?

  2. Why offer a few fixed image widths instead of any width?

Private files (receipts, paid videos, user uploads) can still go through a CDN with short-lived signed URLs: the backend checks permission and signs a URL that expires in minutes; the CDN verifies the signature without asking the backend. Images can be resized at the edge so phones download only what they display.

Key takeaways

  • A CDN keeps copies of your files on servers near users; misses fetch from your origin once.
  • Cache-Control decides what’s cached: public for shared content, private or no-store for anything personal.
  • Put a content hash in file names, cache them forever, and keep only HTML or entry points short-lived.
  • Private files go through the CDN with short-lived signed URLs.
  • Hit rate is a function of key cardinality: whitelist query parameters, avoid per-user Vary, bucket variants.
  • Versioned URLs instead of purges; purges are for emergencies, with a shield to absorb the miss storm.
  • Edge-cache public API responses briefly; keep personalization separate.
  • Signed delivery with short lifetimes, signature-free cache keys, and edge image transforms with fixed breakpoints.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.