CDN caching
Keeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
Why a shared cache at the edge
Picture a sneaker shop that runs a limited drop every Friday at 18:00. The servers live in one data center in Virginia. Most days the site gets about 200 requests per second. At 18:00 on a drop day, 400,000 people open the product page within the same minute, and each page load asks for about 40 files: the HTML, two bundles, a stylesheet, fonts, and a dozen product images.
That is about 16 million requests in one minute, roughly 270,000 per second. The origin, your own servers where responses are created, was sized for 200. It falls over at 18:00:04, and the few who get through wait over 200 ms for every round trip from Mumbai or Sydney to Virginia.
Now the same drop with a CDN (content delivery network) in front. A CDN is a fleet of servers in hundreds of cities, called points of presence or PoPs. Every user talks to a PoP near them. When the PoP already holds a copy of the response, it answers by itself, and the origin never hears about the request.
The browser cache is not enough
The HTTP caching lesson covered the browser cache: Cache-Control, ETag, 304 Not Modified. That cache is private: it belongs to one person on one device. It makes your second visit fast. It does nothing for the first visit, and nothing for the 399,999 other people arriving at 18:00.
A CDN cache is shared. One copy of the product page in the Mumbai PoP serves every user in Mumbai. The first user pays for a trip to the origin; the next ten thousand get the stored copy in about 20 ms. The origin sees roughly one request per object per PoP per lifetime, instead of one per user.
What a CDN cache buys you
- Latency. Round trips drop from 150 to 300 ms across an ocean to 10 to 30 ms, and TTFB (the time to the first byte of the response) drops with them.
- Origin protection. At 98% hits, the origin handles 2% of the traffic. A 100x spike becomes a 2x spike at the origin.
- Cost. Bytes served from the CDN usually cost less than bytes leaving a cloud region, and the origin needs fewer machines.
- Availability. A PoP can keep serving a stale copy while the origin is down.
A shared cache also brings three questions the browser cache never had. Which requests count as the same request? How do you remove a copy from 300 cities when content changes? And how do you make sure one user's personal page never reaches another?
This lesson answers them in that order, then measures the result with the cache hit ratio.
The mental model: a lookup table in every city
Keep one picture in your head: every PoP holds a big lookup table. Each row has a key and a stored response. The key is built from the request, usually the host, the path, and the query string. The value is the full response (status, headers, body) plus two facts the CDN needs: when it was stored and how long it may be served without asking the origin.
When a request arrives, the PoP builds the key, looks it up, and checks the age of the row. That is the whole job. Every feature in this lesson either changes how the key is built, changes how long a row lives, or removes rows on purpose.
Think of a chain of libraries. Each branch keeps its own copies of popular books, orders a missing one from the central warehouse, and keeps it for the next reader. The Paris branch knows nothing about Mumbai's shelves. That last part matters: each PoP has its own cache, so a file can be a hit in Mumbai and a miss in Paris at the same moment.
One request, step by step
Follow a product page, GET /shoes/air-1, through the Mumbai PoP. The origin sends Cache-Control: public, max-age=0, s-maxage=300. In plain words: browsers must check every time, shared caches may keep it for 300 seconds. (The HTTP caching lesson explains s-maxage in detail.)
- 08:00:00, first user. No row for the key
shop.com/shoes/air-1, so the PoP asks the origin, which renders the page in 180 ms and answers200 OKwithETag: "v41". The PoP stores it and answers withAge: 0. A miss. - 08:00:37, second user. Row found, stored 37 seconds ago, allowed 300. The PoP answers at once with
Age: 37. A hit. - 08:05:10, a later user. The row is 310 seconds old: stale. The PoP asks the origin with
If-None-Match: "v41", gets304 Not Modifiedwith no body, resets the row's clock, and serves its copy. A revalidation. - 08:05:10, 400 more users in the same second. A good CDN sends one request to the origin and holds the other 400 until it returns. This is request collapsing. Without it, a popular page that expires sends a burst of identical requests to the origin: the thundering herd.
Age header tells you which kind of answer you got: 0 means it just came from the origin, anything higher means a stored copy.Reading what the CDN did
You can see each of these outcomes in the response headers, which makes debugging a CDN much less mysterious. Open DevTools, click a request, and look for these.
| Header | Who sends it | What it tells you |
|---|---|---|
Age: 37 | Any shared cache (standard HTTP) | The copy was stored 37 seconds ago. 0 or missing usually means it came from the origin just now. |
Cache-Status: ExampleCDN; hit; ttl=263 | CDNs that follow RFC 9211 | A standard way to say hit or miss, the time to live left, and sometimes why it was a miss (fwd=miss, fwd=stale). |
cf-cache-status: HIT | Cloudflare | HIT, MISS, EXPIRED, STALE, REVALIDATED, BYPASS, or DYNAMIC (not eligible for caching at all). |
x-cache: HIT, MISS | Fastly, CloudFront, others | Hit or miss per cache layer. With two layers you may see two values, one per layer. |
When a page is slow, the first question is always: was it a hit? If Age stays at 0 on every reload, the CDN is not caching that URL at all, and no amount of origin tuning will make it feel like a cached page.
Check your understanding · Exercise 1
You reload a product page twice, ten seconds apart, from the same city. Both responses show Age: 0 and cf-cache-status: DYNAMIC. What does that tell you? Pick one.
Edge caching rules: what gets stored, and for how long
A CDN decides whether to store a response from two places: the headers your origin sends and the rules you configure at the CDN. Headers travel with each response, so the team that writes the page controls them. Rules can override headers for whole groups of URLs, such as everything under /static/.
Most teams use both: headers carry the per-response truth (this page is public, this one is private), rules carry the policy (strip tracking parameters, never cache /api/cart). When they disagree, the rule usually wins, which is both its power and its danger.
Headers that steer the edge
You often want different lifetimes for the browser and the CDN. HTML is the classic case: the CDN may keep it for five minutes because you can purge it, but the browser should check every time because you cannot delete a copy in a user's browser. The HTTP caching lesson covers these headers in depth; here is what each means at the edge.
| Header | Read by | Use it for |
|---|---|---|
Cache-Control: s-maxage=300 | Shared caches only (CDNs, proxies) | A CDN lifetime that differs from the browser's max-age. Browsers ignore s-maxage. |
CDN-Cache-Control: max-age=300 | CDNs that support RFC 9213, such as Cloudflare | Same idea, in its own header, so CDN policy is never mixed into the browser's rules. Browsers ignore it. |
Surrogate-Control: max-age=300 | Fastly, some others | An older CDN-only header, and the one Fastly reads instead of CDN-Cache-Control. The CDN removes it before the response goes to the browser. |
Cache-Control: private | Everyone | Only the user's own browser may store it. A CDN must not. Use it for anything personal. |
Cache-Control: no-store | Everyone | Nobody stores it, anywhere. Use it for secrets: account pages, payment steps, tokens. |
stale-while-revalidate=60 | Browsers and many CDNs | After the lifetime ends, serve the old copy for up to 60 more seconds while fetching a new one in the background. |
stale-if-error=86400 | Many CDNs | If the origin fails or times out, keep serving the old copy for up to a day instead of an error. |
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=0, must-revalidate
CDN-Cache-Control: max-age=300, stale-while-revalidate=60, stale-if-error=86400
ETag: "v41"
Cache-Tag: product-air-1,category-running
What a CDN refuses to cache by default
Even with friendly headers, CDNs are careful by default. The exact defaults differ between vendors, so always check yours, but these are common:
- Methods other than
GETandHEAD. APOSTchanges things on the server, so it always goes to the origin. - Responses with
Set-Cookie. Such a response might be starting a session; storing it would hand that cookie to the next user. Many CDNs, Fastly and Cloudflare among them, skip these unless a rule forces caching. - Requests with an
Authorizationheader. A shared cache must not reuse the response unless the response explicitly allows it, for example withpublicors-maxage. - HTML, on some CDNs. Cloudflare, for example, decides by file extension: it caches
.js,.css, images, and fonts by default, but not HTML or JSON until you add a rule. Others follow your headers and apply a default lifetime when there are none. - Most errors. Server errors such as
500are usually not stored, while a404often is, for a few minutes. That negative caching protects the origin from bots asking for missing pages, but a long lifetime on a404can hide a page that was just published.
Check your understanding · Exercise 2
Which of these responses should a well-configured CDN store and share between users? Select all that apply.
Cache keys and Vary: which requests are the same request
The cache key is the string the PoP looks up in its table. Two requests with the same key get the same stored response; two with different keys are strangers, even if the origin would send identical bytes. So the key decides correctness (does each user get the right response?) and efficiency (how many users share each copy?).
By default, most CDNs build the key from the host, the path, and the full query string. shop.com/shoes?page=2 is one key. Headers and cookies are not in the default key. (CloudFront's recommended cache policy leaves out the query string too.) That default is a reasonable start, and it is wrong in both directions for many real sites.
utm_source.Two ways to get the key wrong
- Too narrow: something that changes the response is missing. The page at
/shoesshows prices in the currency from acurrencycookie, but the cookie is not in the key. The first visitor from India stores a page in rupees, and every visitor after them gets rupees. A correctness bug, and often a privacy bug. - Too wide: something that does not change the response is included. Newsletter links add
?utm_source=mail; ads add a uniquegclid=orfbclid=per click. Each is a new key, so each click is a miss. One page splits into thousands of copies that each get one hit, and your hit ratio quietly collapses.
The rule fits in one sentence: the key must contain every input that changes the response, and nothing else. Everything in this chapter is a tool for getting closer to that rule.
Normalizing the query string
Query strings are the biggest source of accidental keys. Every major CDN can apply these normalizations in a rule before the lookup:
- Drop tracking parameters:
utm_*,gclid,fbclid, and similar. Analytics scripts still read them from the URL bar; only the key changes. - Sort the remaining parameters, so
?color=red&size=9and?size=9&color=redshare one key. - Prefer an allowlist for pages you know well. The listing reads only
page,sort, andcolor, so the key keeps exactly those and ignores whatever a partner invents next month. - Normalize values the origin treats the same, such as lowercasing
?Color=Red.
Be careful with allowlists: if the origin later reads a new parameter, say ?view=grid, and nobody adds it to the key, every user gets whichever view was cached first. Keep the key rule next to the code that reads the parameters.
The Vary header
Sometimes the origin chooses the response from a request header, not the URL. The Vary response header says which ones. Vary: Accept-Encoding means: I chose this body based on Accept-Encoding, so store a separate copy for each value. A cache that honors Vary adds those header values to the key for that URL.
| Vary value | How many distinct values arrive | Verdict |
|---|---|---|
Accept-Encoding | Many strings, but CDNs normalize them to a few: br, gzip, none | Fine. Almost every site needs it, and CDNs handle it well. |
Accept-Language | Thousands of raw strings like en-US,en;q=0.9,hi;q=0.8 | Risky raw. Normalize to your 5 to 10 supported languages at the edge, or put the language in the URL. |
User-Agent | Tens of thousands | Avoid. Each browser version gets its own copy. Derive a device class at the edge instead. |
Cookie | Nearly one per user | Avoid. It makes the page effectively uncacheable. Pick the one cookie that matters and key on its value. |
* | Infinite | Means "never reuse". Same as not caching. |
Vendors treat Vary differently. Fastly honors any header listed in it. Cloudflare honors Vary: Accept-Encoding, plus Vary: Accept for images when you turn that on, and ignores the rest, so Vary: Cookie there separates nobody and you use custom cache keys instead. Check your CDN's documentation, and test with two requests that differ only in that header.
Custom keys: put exactly what matters in
The cleaner tool is a custom cache key: a CDN rule listing which query parameters, headers, and cookies form the key, often after transforming them. Good additions are small, bounded values:
- A device class (
mobile,tablet,desktop) worked out at the edge fromUser-Agent, if the HTML truly differs. Three values instead of thousands. - A country from the CDN's geolocation, if prices or legal banners differ by country.
- One cookie value with few possible values, such as
ab_bucket=borcurrency=INR. Never the wholeCookieheader, never a session id.
Each value multiplies the copies. A page keyed on 3 device classes, 4 currencies, and 2 experiment buckets has 24 copies per PoP, each with its own first miss: fine for a busy page, terrible for one with 10 views a day. Try it in the lab: change the rules and watch which requests share a copy.
- Key A:
/shoes?page=2&color=red - Key B:
/shoes?color=red&page=2 - Key C:
/shoes?page=2&color=red&utm_source=mail - Key D:
/shoes?page=2&color=red&gclid=Xa91 - Key E:
/shoes?color=red&page=2&utm_source=ads
Decision Your store serves English, Hindi, and French. How should the CDN keep the language versions apart?
What we pick: Put the language in the URL when you can: it needs no cache-key logic, works with search engines, and cannot be keyed wrong. Normalize the header (or key on a lang cookie) only when URLs cannot change, and test that each language really gets its own copy.
Check your understanding · Exercise 3
A marketing email with 200,000 unique tracking links (?utm_source=mail&uid=...) goes out at 09:00 and the origin falls over, even though the landing page has a 10-minute CDN lifetime. Which fixes address the cause? Select all that apply.
Caching HTML vs hashed assets
A web app sends two very different kinds of files. Hashed assets have a fingerprint of their content in the name, like app.3f9a1c.js. HTML documents live at stable URLs like /shoes/air-1 that keep their name when their content changes.
This decides everything. A hashed file never changes: new content gets a new name, so every cache can keep it forever. An HTML URL can change at any moment, so every cache that holds it needs a way to find out.
Hashed assets: cache forever, never purge
Serve hashed files with Cache-Control: public, max-age=31536000, immutable. At the CDN they become near-permanent rows that you never purge. A deploy that changes the bundle produces app.8b20e4.js, a brand-new key, and the new HTML points to it.
The mistake to avoid is deleting old assets on deploy. A user who loaded the old HTML five minutes ago will lazy-load checkout.3f9a1c.js when they click Buy. If that file is gone, the click fails with a chunk-load error.
Keep old assets for days, longer than your longest HTML lifetime plus a typical session. Some hosts call this skew protection.
HTML: short lifetimes, or long lifetimes with purging
Rendering a product page might cost 150 ms of origin time. Caching it for even 60 seconds means a page viewed 1,000 times a minute is rendered once a minute per PoP. But the page must also update when the price changes. There are three common answers.
Decision How long should the CDN keep a public, server-rendered HTML page?
What we pick: For public pages that change a few times a day, a long CDN lifetime with tag-based purging gives the best hit ratio and fresh content. Without a purge pipeline yet, start with a short TTL plus stale-while-revalidate: it is hard to get wrong. (Frameworks with static generation and revalidation, covered in the SSG and ISR lesson, package the same ideas.) Keep the browser's max-age at 0 for HTML in every case.
The order of a safe deploy
Together these rules give a deploy sequence that never shows a broken page: new assets exist before any HTML points at them, and old assets outlive any HTML that still points at them.
- Upload the new hashed assets next to the old ones. Nothing references them yet, so nothing changes for users.
- Switch the origin to serve the new HTML.
- Purge the HTML at the CDN (by tag, or everything tagged
html). PoPs fetch the new HTML on the next request. - Days later, delete assets that no live HTML can reference anymore.
| Response | Browser | CDN | Why |
|---|---|---|---|
| Hashed JS, CSS, fonts | max-age=31536000, immutable | Same, a year | The name changes when the content does. |
| Images with a version in the URL | Long, a month to a year | Long | Same reason. Without a version, use days and purge on change. |
| Public HTML | max-age=0, must-revalidate | Minutes with SWR, or a day with purging | Must update on content change; only the CDN copy can be purged. |
| Public JSON (catalog, prices) | max-age=0 or a few seconds | Seconds to minutes | Shared by all users; short lifetimes still collapse traffic. |
| Personal HTML or JSON | private | Never | One user's data must never reach a shared cache. |
Check your understanding · Exercise 4
After each deploy, error logs show a spike of failed requests for old JavaScript chunks, lasting about an hour. HTML has CDN-Cache-Control: max-age=3600, and the deploy script deletes the previous build's files. What would fix it? Select all that apply.
Personalized pages: caching what is shared
Look at a product page for a signed-in user. The header says "Hi, Asha" with 3 items in the cart, and a row shows "Recommended for you". Everything else (photos, title, price, reviews) is the same for every visitor: often more than 90% of the bytes.
If the server renders the greeting into the HTML, the whole page becomes personal and must be private, and you lose the CDN on your busiest pages for a few dozen bytes. Every approach below uses one idea: separate the shared part from the personal part, cache the shared part at the edge, and deliver the personal part another way.
Four ways to split the page
- Client-side fragments. The CDN caches one public page with empty slots; the browser then calls
/api/me(sentprivate) and fills them. Simple and common, at the cost of a second request and a slot that pops in late, so reserve its space to avoid layout shift. - Edge-side includes (ESI). The cached HTML contains include tags pointing at fragment URLs. The CDN fetches each fragment, stitches them in, and sends one complete document. Supported by Akamai, by Varnish, and by Fastly in a basic form.
- Edge compute. A small program at the PoP (Cloudflare Workers, Fastly Compute, Netlify Edge Functions) reads the cached shell, fetches the personal piece, and streams the result. ESI's idea as ordinary code, with full control over timeouts.
- Segments in the key. Show per-segment content instead of per-user: logged in or not, country, experiment bucket. Put that one bounded value in the key. Ten segments means ten copies per page, each shared by thousands.
<header>
<a href="/">Shop</a>
<esi:include src="/fragments/user-menu" />
</header>
<main>
<!-- public product content, cached for a day -->
</main>
Cookies and the cache key
Cookies are where personalization meets the key, and where most leaks happen. The Cookie header holds every cookie for the site: session id, analytics ids, consent, experiment buckets. Keying on the whole header (or sending Vary: Cookie) gives each user their own copy, the same as not caching. Ignoring cookies is fine only if the response truly depends on none of them.
The safe pattern: make responses that render personal data private at the origin (or bypass the cache when a session cookie is present), and if a page varies by something bounded, key on only that cookie's value. Many sites split traffic in two: anonymous visitors, often the majority, get fully cached pages; signed-in users get fragment-based or uncached ones.
Decision A news site shows a personal "Your topics" box on its article pages. How should it deliver that box?
What we pick: Start with a client-side fragment: the box is below the fold and not needed for the first paint, so a late arrival costs little, and the article itself stays cached for every reader. Move to edge assembly only if the personal part is above the fold or affects LCP.
Check your understanding · Exercise 6
After enabling "cache everything" on /shop/*, a support ticket says a visitor saw another person's name in the header. What could explain it? Select all that apply.
Cache hit ratio, tiered caches, and origin shielding
The cache hit ratio is the share of requests the CDN answered from its own copies. It tells you whether your CDN setup works. A static site might sit at 99%, a store with server-rendered pages and good keys at 90 to 95%, and a site with fragmented keys at 30% without knowing it.
What matters for the origin is the other side: origin requests = total requests x (1 - hit ratio). So small changes at the top are large at the bottom. Going from 98% to 99% halves origin traffic: during the sneaker drop, 5,400 requests per second become 2,700.
Request hit ratio and byte hit ratio
CDNs report two versions. The request hit ratio counts requests and predicts origin CPU load. The byte hit ratio counts bytes and predicts your egress bill. A site whose tiny icons hit but whose 5 MB videos often miss might show 96% of requests as hits but only 70% of bytes.
Watch both, split by content type.
Where misses come from
- Cold misses. The first request for a key at each PoP. With 300 PoPs, a new file can cost up to 300 origin fetches.
- Expiry misses. The lifetime ran out, so the copy is refetched or revalidated.
- Eviction misses. Storage is limited, so a PoP drops rarely used rows. The long tail of your catalogue is evicted long before its lifetime ends.
- Fragmentation misses. The same content stored under many keys, each paying its own cold misses.
- Not cacheable at all.
private,no-store,Set-Cookie, or no rule allowing storage. Often the largest group.
A small model you can reason with
Take one URL at one PoP, getting a steady r requests per second, with a lifetime of T seconds. After each miss, the copy serves about r x T hits before it expires, then the next request misses again. So the hit ratio for that URL at that PoP is about rT / (1 + rT).
A home page at 50 requests per second per PoP with a 60-second lifetime: rT = 3,000, hit ratio 99.97%. A product page seen once every 10 minutes per PoP, same lifetime: rT = 0.1, hit ratio 9%. Hit ratio depends on traffic per key per PoP. More PoPs, more keys, or shorter lifetimes spread traffic thinner and lower it.
Tiered caching and origin shields
The per-PoP problem has a structural fix: a second layer. With tiered caching, edge PoPs that miss ask a larger regional cache before the origin. With an origin shield, one chosen location sits in front of the origin and every edge miss goes through it. Fastly calls this shielding, CloudFront offers Origin Shield on top of its regional edge caches, and Cloudflare calls it Tiered Cache.
Now 300 PoPs share the shield's copy, and the origin sees roughly one fetch per object per lifetime. The long-tail page that missed 91% of the time at each PoP mostly hits at the shield, which collects all its traffic. With request collapsing at the shield, a purge reaches the origin as one request per object.
The costs: an edge miss takes an extra hop, so place the shield near the origin, not near users; and the shield is a concentration point, so allow failover to direct origin fetches. Use the calculator to see the model at work: raise the number of URLs to simulate a long tail, then turn the shield on.
Check your understanding · Exercise 7
A site serves 40,000 requests per second through the CDN at a 95% hit ratio. A key cleanup raises it to 99%. What happens at the origin? Pick one.
When it goes wrong: poisoning, deception, and outages
A shared cache multiplies whatever it stores. A good response is served to a million people for the cost of one; so is a bad one. Most serious CDN incidents follow one pattern: something private, wrong, or attacker-controlled got stored under a key that ordinary users request.
Two attacks are worth knowing by name: cache poisoning and web cache deception. Both exploit a gap between what the cache thinks makes two requests the same and what the origin actually uses to build the response.
Cache poisoning
In cache poisoning, an attacker crafts a request whose harmful response the CDN stores under a normal key. The trick is an unkeyed input: a header or parameter the origin uses but the key ignores.
- The origin builds absolute script URLs from the
X-Forwarded-Hostheader when present, a leftover from an old proxy setup. - The attacker requests
GET /withX-Forwarded-Host: evil.example. The key is stillshop.com/. - The origin renders a page whose script tag points at
https://evil.example/app.js, and the CDN stores it for 10 minutes. - Every visitor to the home page at that PoP now runs the attacker's script. The attacker repeats the request before expiry to keep it there.
A quieter variant is cache-poisoned denial of service: an oversized or malformed header makes the origin return a 400, and the CDN caches that error for the page's URL. The defences for both:
- Never build a response from an input that is not in the key. Add it to the key, or strip it at the edge.
- Strip forwarding headers such as
X-Forwarded-Hostfrom client requests, so only the CDN sets them. - Do not cache error responses, or only for a few seconds.
- Parse URLs the same way at the edge and the origin. Differences in how each reads
;, encoded characters, or repeated parameters let an attacker hide input from the key.
Web cache deception
Web cache deception goes the other way: the attacker tricks the cache into storing a victim's private page. They send the victim a link to https://shop.com/account/orders/x.css. Many frameworks ignore the unknown tail and render the account page.
The CDN sees .css, treats it as a static file, and caches it. The attacker opens the same URL and reads the victim's orders.
The defences: return 404 for unknown paths instead of falling back to a real page, send private or no-store on every personal response, and set the CDN to respect origin headers rather than caching by file extension. Some CDNs also check that the Content-Type matches the extension before caching.
Outages and stale content
The CDN can also save you during an outage. With stale-if-error, a PoP that cannot reach the origin keeps serving its last good copy, so public pages stay up. Set it on HTML and public JSON, with a window you are comfortable with: often an hour to a day.
The opposite failure is content that will not go away: a wrong price that survives the fix because a purge failed or raced the database. Log every purge and its result, alert when a purge call fails, and keep a runbook: purge by tag, then by URL, then everything.
Check your understanding · Exercise 8
A security scan shows that sending X-Forwarded-Host: test.example changes the links in your cached home page. Which changes close the hole? Select all that apply.
In real products and in a system design interview
Frameworks now write most of these headers for you, which makes it more important to know what they mean. Translate each caching feature back into this lesson's pieces: a lifetime, a key, a tag, or a purge.
- Next.js sends
s-maxageandstale-while-revalidatefor static and revalidated pages, and offersrevalidateTagandrevalidatePath: tag and URL purges for its own cache, the same idea as surrogate keys. - Remix and React Router let each route export a
headersfunction, so the public-or-private decision sits next to the route's data loading. - Fastly users tag responses with
Surrogate-Keyand purge by key from their CMS, often with soft purges. - Cloudflare uses Cache Rules for lifetimes and custom keys,
Cache-Tagfor tag purges, and Tiered Cache for a shield layer. - CloudFront puts the key in a cache policy (which headers, cookies, and query strings to include) and offers Origin Shield.
When to bring it up
Bring up CDN caching in any design that is read-heavy, global, or spiky: a news site, a product page, a marketing site with launches, a media-heavy feed. Draw the CDN between users and the origin in the high-level architecture, then come back to it for performance and scale.
For an app that is mostly personal data behind a login (an email client, a dashboard), keep it short: the CDN serves hashed assets and the app shell, and API responses are private. That shows judgement, not a gap.
What to draw and say
- Draw the path: browser, edge PoP, shield, origin.
- Give each response type a policy: hashed assets for a year; public HTML at the CDN for minutes with
stale-while-revalidate, or longer with tag purges; personal dataprivate. - Name the key: host, path, allowlisted query parameters, and at most one bounded segment such as country.
- Explain freshness: when a price changes, the backend purges its tag after the write commits; browsers keep
max-age=0on HTML. - Estimate: traffic, hit ratio, origin load. "At 50,000 requests per second and a 97% hit ratio, the origin sees 1,500. A shield cuts the cold misses from our 300 PoPs."
- Close with risk: personal pages never shared, unkeyed headers stripped,
stale-if-errorfor outages.
| Level | What a strong answer shows |
|---|---|
| Junior | Knows a CDN keeps shared copies near users, that hashed assets can be cached for a long time, and that personal pages must not be shared. |
| Mid | Sets different browser and CDN lifetimes for HTML, designs a cache key without tracking parameters, purges on content change, and splits personal fragments from the cached page. |
| Senior | Reasons about hit ratio per key per PoP, adds shielding and request collapsing, plans tag-based purges and their races, and names poisoning and deception risks with defences. |
Check your understanding · Exercise 9
You are designing a global news site with 20 million daily readers and breaking-news spikes. Which plan fits best? Select all that apply.
Summary
- A CDN cache is shared: one copy at a PoP serves every nearby user, so the origin sees about one request per object per PoP per lifetime instead of one per user.
- Give browsers and the CDN different lifetimes: you can purge the CDN, never the browser, so HTML gets browser
max-age=0and a longer CDN lifetime vias-maxageorCDN-Cache-Control. - The cache key must hold every input that changes the response and nothing else: drop tracking parameters, avoid
VaryonUser-AgentorCookie, and add only small, bounded segments. - Hashed assets are cached for a year and never purged; old ones must stay available after a deploy. HTML is short-lived or purged on change.
- Tag responses with the data they contain (
Surrogate-Key,Cache-Tag) and purge by tag after the write is visible, so one call removes every affected page. - Split personal content from shared content with client-side fragments, ESI, edge code, or segment keys, so the page itself stays cacheable.
- Hit ratio depends on traffic per key per PoP; origin load is total x (1 - hit ratio). Fix fragmentation first, then add tiered caching or an origin shield.
- Every response must say whether a stranger may see it. Strip unkeyed headers, do not cache errors, and use
privateon personal pages to block poisoning and deception.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
How would you cache a server-rendered product page at the CDN, and keep the price fresh?
Send Cache-Control: max-age=0, must-revalidate for browsers and a separate CDN lifetime, for example CDN-Cache-Control: max-age=86400, stale-while-revalidate=60, stale-if-error=86400. Browsers always check; the CDN keeps a copy I can purge.
Tag the response with the data it shows: product-air-1, category-running. When the price changes, the backend purges product-air-1 after the write is committed and visible to the origin's reads. Every page and JSON response showing that product drops from every PoP within about a second.
Keep the key to host, path, and the query parameters the page reads. Personal parts like the cart count come from a private API call or an edge fragment, so the page stays shareable.
Question 2
What is a cache key, and what goes wrong if it is too narrow or too wide?
The cache key is what the CDN looks up to decide whether two requests can share a stored response. The default is usually host, path, and query string.
Too narrow, missing an input that changes the response, and users get the wrong version: the wrong currency or language, or in the worst case another user's page.
Too wide, including inputs that do not change the response, and the same content is stored under many keys: tracking parameters, raw User-Agent, or the whole Cookie header. Each key pays its own misses, and the hit ratio collapses.
The fix is normalization at the edge: drop tracking parameters, sort or allowlist the rest, and add only small, bounded values such as device class or one segment cookie.
Question 3
Our CDN hit ratio is 60%. How would you investigate and raise it?
First, split it: by content type, by status (HIT, MISS, EXPIRED, BYPASS, DYNAMIC), and by PoP. Bypass and dynamic responses are often the largest group and are a header or rule problem, not a lifetime problem.
Then look at the most-missed URLs for fragmentation: the same path with many query strings, or Vary on a high-variety header. Normalize the key.
Then check lifetimes and traffic per key per PoP. Long-tail pages miss at the edge because each PoP sees them rarely; a tiered cache or origin shield lets all PoPs share one copy.
Finally, move personal content out of pages that are otherwise public so those pages can be cached at all.
Question 4
What is web cache poisoning, and how do you prevent it?
An attacker sends a request with an input the origin uses but the cache key ignores, such as X-Forwarded-Host. The origin reflects it into the response, and the CDN stores that response under a normal key, so every user gets the attacker's version.
Prevent it by never building responses from unkeyed inputs, stripping forwarding headers at the edge, not caching error responses, and keeping URL parsing consistent between the edge and the origin.
Its cousin, web cache deception, tricks the CDN into caching a victim's private page under a static-looking URL. The defence is private or no-store on every personal response, and 404 for unknown paths.
Question 5
Why add an origin shield when the CDN already has hundreds of PoPs?
Each PoP has its own cache, so a new or expired object can cause one origin fetch per PoP: 300 PoPs means up to 300 fetches per object per lifetime, and a purge-all sends them all at once.
A shield sits between the PoPs and the origin. Edge misses go to it, it collapses identical requests, and the origin sees about one fetch per object per lifetime. Long-tail pages that rarely hit at any single PoP hit at the shield, which sees all their traffic.
The cost is an extra hop on edge misses and a concentration point, so place the shield near the origin and allow failover to direct fetches.
Practice it
These design problems lean on cdn caching.
- Design a cookie consent managerEasy
- Design an internationalization systemMedium
- Design Stack OverflowMedium
- Design a blogging platform like MediumMedium
- Design a grocery delivery slot pickerMedium
- Design WikipediaMedium
- Design a plan to make a slow e-commerce site fastMedium
- Design an online exam platformMedium
- Design a documentation site like MDNMedium
- Design an AI image generation studioMedium
- Design an A/B testing system for the frontendHard
- Design a live cricket score page for 50 million fansHard
- Design Twitter (X)Hard
- Design LinkedInHard
- Design an e-commerce site like AmazonHard
- Design a seat selection map for eventsHard
- Design an online judge like LeetCodeHard
- Design a collaborative pixel canvas like r/placeHard
- Design a Twitch-style live stream pageHard
- Design a micro-frontend shellHard
References
- RFC 9111: HTTP Caching The standard: shared caches, s-maxage, Vary, Age.
- RFC 9213: Targeted HTTP Cache Control Defines CDN-Cache-Control.
- RFC 9211: The Cache-Status HTTP Response Header Field A standard way for caches to report hits and misses.
- MDN: Vary
- Cloudflare: Default cache behavior Which file extensions are cached by default, and why HTML and JSON are not.
- Fastly: Working with surrogate keys
- Cloudflare: Purge cache by cache-tags
- PortSwigger Web Security Academy: Web cache poisoning
- PortSwigger Research: Practical Web Cache Poisoning James Kettle's 2018 research, including the X-Forwarded-Host attack.
- PortSwigger Web Security Academy: Web cache deception
Read next
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.
- DNS, CDNs, and the edgeHow a name becomes an address, how that address leads to a server a few milliseconds from the user instead of an ocean away, and what code and files a frontend should run and keep there.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Static generation and incremental regenerationRender pages to HTML once, ahead of time, and serve the same files from a CDN to everyone; incremental regeneration then refreshes single pages after launch, so you get static speed without rebuilding the whole site for every edit.
- From URL to pixelsEverything that happens between pressing Enter and seeing a page: DNS, TCP, TLS, the HTTP exchange, redirects, parsing, and subresources, with where the time goes on a real phone, so you know which part to fix when a page is slow.