CDN and edge caching
The edge hands one copy to everyone whose request builds the same key. Decide what is shared, for how long, and how to retract it.
Predict first
Before you read: predict
ExamHall's exam start page, /exam/ECON-201/start, greets the candidate by name, read from the session cookie. The origin sends it with Cache-Control: public, max-age=30, s-maxage=60. The CDN caches whatever that header allows and uses its default cache key. It receives five requests for the page, one at a time: Amal at 09:00:00, Ben at 09:00:20, Amal again at 09:00:40, Chidi at 09:00:50 and Dana at 09:01:15.
How many of the five reach the origin?
The 30-second version
An edge cache stores one copy per cache key: by default the method and full URL, query string included; cookies and headers count only if you add them. Everyone with that key gets that copy until s-maxage (else max-age) seconds after it was stored, then refetched. private or no-store keeps a response out. Purges or new URLs retire copies.
The edge hands one copy to everyone whose request builds the same key. Decide what is shared, for how long, and how to retract it.
Step through the code
Trace of the opening question
Play options
Ready: press Play or step with Next frame
Row 1 of 5 · Time 09:00:00
Starting row · computed by comparing each row with the one before
- Time: 09:00:00
- Request: Amal
- Key:
GET /exam/ECON-201/start - Stored copy: none: fetch, store "Amal"; lifetime 60 (
s-maxage, notmax-age) - Served: "Amal"
Rule · step 1
The edge builds a cache key from the method and the full URL, query string included. Headers and cookies join the key only if configured.
Rule · step 2
A shared cache takes its lifetime from s-maxage, else from max-age. With private or no-store it must not store the response.
- Time
- 09:00:00
- Request
- Amal
- Key
GET /exam/ECON-201/start- Stored copy
- none: fetch, store "Amal"; lifetime 60 (
s-maxage, notmax-age) - Served
- "Amal"
- Step
- 1, 2
| Time | Request | Key | Stored copy | Served | Step |
|---|---|---|---|---|---|
| 09:00:00 | Amal | GET /exam/ECON-201/start | none: fetch, store "Amal"; lifetime 60 (s-maxage, not max-age) | "Amal" | 1, 2 |
| 09:00:20 (changed) | Ben (changed) | same key (changed) | age 20 < 60: fresh (changed) | "Amal" | 3, 5 |
| 09:00:40 (changed) | Amal (changed) | same key | age 40 < 60: fresh (changed) | "Amal" | 2, 3 |
| 09:00:50 (changed) | Chidi (changed) | same key | age 50 < 60: fresh (changed) | "Amal" | 3, 5 |
| 09:01:15 (changed) | Dana (changed) | same key | age 75: expired; fetch, store "Dana" (changed) | "Dana" (changed) | 3 |
Space plays or pauses · ←/→ one row · Home/End first/last
Rows come from the pack's own trace table.
How it works
Step 1 of 8
See it run
In the kitchen
- one rack slot per leaflet title, whoever asks → the cache key (step 1)
Analogy: A network of tourist information offices
Head office prints leaflets; every town office keeps them on a rack and hands them to visitors. The rack has one slot per leaflet title, and each rack copy carries a "take off the rack at" time.
| In the offices | In the edge cache |
|---|---|
| one rack slot per leaflet title, whoever asks | the cache key (step 1) |
| the rack's "take off at" time, separate from how long a visitor keeps theirs | s-maxage (step 2) |
| a personal booking confirmation never goes on the rack | private, no-store (step 2) |
| each town office stocks its own rack on its first request | each PoP keeps its own copies (step 4) |
| head office recalls every leaflet that mentions route 5 | purge by tag (step 7) |
| a reprint under a new title code, so old stock is simply never asked for | a versioned URL (step 7) |
| a regional office that restocks the town offices | an origin shield (step 8) |
In real terms: a CDN hands a key's stored response to every request building that key while its shared-cache lifetime lasts.
Breaks down when: a clerk would notice a visitor's name printed on a leaflet; an edge never looks inside the body. Wrong inference: "the CDN will spot a personal page." It will not, which is why step 5 exists.
Contrasting case: a clerk who phones head office for every visitor. Nothing leaks, and head office carries all the load. That is no-store everywhere, or a key that includes the session.
Examples, run for real
Each script sets a virtual clock between requests, so every age is exact.
4.1 ExamHall: the start page that greeted the wrong candidate
EXH-688: candidates saw someone else's name on the exam start page. The shell was public, s-maxage=60 and printed the name from the cookie. MR !1147 split it: the shared shell stays at the edge, and the name comes from /api/me, sent as private, no-store.
BEFORE shell = public, s-maxage=60 and carries the name
t= 0 Farah /exam/ECON-201/start MISS "ECON-201: Good luck, Farah"
t=15 Gil /exam/ECON-201/start HIT "ECON-201: Good luck, Farah"
t=45 Hana /exam/ECON-201/start HIT "ECON-201: Good luck, Farah"
AFTER shell = public, s-maxage=60, no name · /api/me = private, no-store
t= 0 Farah /exam/ECON-201/start MISS "ECON-201 shell" · /api/me MISS "Farah"
t=15 Gil /exam/ECON-201/start HIT "ECON-201 shell" · /api/me MISS "Gil"
t=45 Hana /exam/ECON-201/start HIT "ECON-201 shell" · /api/me MISS "Hana"
origin: /exam/ECON-201/start 1, /api/me 3
ALT shell as BEFORE, but the edge key includes the session cookie
origin: /exam/ECON-201/start 3
The body differed by the cookie, the key did not (steps 1, 5). Keying on the cookie also stops the leak, but every candidate then misses; splitting keeps one shared copy.
4.2 ExamHall: notices going stale, then the origin failing
/api/exams/ECON-201/notices is sent with public, max-age=60, stale-while-revalidate=30, stale-if-error=600.
t= 0 200 MISS body=v1 age=0 origin calls +1
t= 30 200 HIT body=v1 age=30 origin calls +0
t= 65 notice v2 published
t= 70 200 STALE body=v1 age=70 origin calls +1
t= 75 200 HIT body=v2 age=5 origin calls +0
t=100 origin starts answering 503
t=140 200 STALE body=v2 age=70 origin calls +1
t=200 200 STALE-IF-ERROR body=v2 age=130 origin calls +1
t=800 503 EXPIRED body=down age=- origin calls +1
At 70 the request gets v1 at once while the background fetch stores v2 (step 6; this edge always takes that permission). With s-maxage neither window would apply. At 200 only stale-if-error still applies, so the 503 is hidden. At 800 it is past 60 + 600 s and the error reaches the candidate.
4.3 ExamHall: an erratum on three pages
Question 4 appears on the exam page, the question page and the day's timetable. Each response carries a Surrogate-Key naming every exam it shows.
/exams/ECON-201 Surrogate-Key: exam-econ201
/exams/ECON-201/q/4 Surrogate-Key: exam-econ201 q-econ201-4
/timetable/2026-10-10 Surrogate-Key: day-2026-10-10 exam-econ201 exam-law110
purge URL /exams/ECON-201/q/4 removed 1 | /exams/ECON-201 HIT OLD · /exams/ECON-201/q/4 MISS new · /timetable/2026-10-10 HIT OLD
purge tag exam-econ201 removed 3 | /exams/ECON-201 MISS new · /exams/ECON-201/q/4 MISS new · /timetable/2026-10-10 MISS new
versioned paper JSON v7 HIT "paper v7" · v8 MISS "paper v8" · v8 again HIT | origin calls for the paper 2
A URL purge removes one copy; the tag reaches every page that declared it (step 7). The paper JSON needs no purge: the new page links ?v=8, a new key.
4.4 Proof domain, ticketing: on-sale behind an origin shield
Event 42's seat map is public, s-maxage=60. The London, Paris and Frankfurt PoPs get their first request at 0, 10 and 50 s.
no shield LHR@0 MISS age=0 · CDG@10 MISS age=0 · FRA@50 MISS age=0 | origin 3 | then FRA@65 HIT, origin 3
with shield LHR@0 MISS age=0 · CDG@10 MISS age=10 · FRA@50 MISS age=50 | origin 1 | then FRA@65 EXPIRED, origin 2
With the shield, three PoP misses cost the origin one fetch (step 8). Frankfurt's copy arrived already 50 s old, so it expires at 60 s of total age, not 60 s after Frankfurt stored it.
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A streaming app's /browse/rows is public, s-maxage=600. The CDN adds a device class to the cache key, reduced at the edge from User-Agent to mobile, tablet or desktop. There is no origin shield, and each viewer reaches the nearest PoP. Within one minute, one at a time, the rows are requested by a London iPhone, a London Windows laptop, a London Android phone, a Paris iPhone and a Paris Mac.
How many of the five requests reach the origin?
Question 2 of 5
/products/881 was stored at the edge with Cache-Control: public, s-maxage=86400 and a price of £89. Ten minutes later the price drops to £79 in the database. Shoppers reach the page from bookmarks, search results and old emails.
Which one change makes that URL show £79 within a minute?
Question 3 of 5
A chat app's sticker sheet /stickers/party-pack.png is public, s-maxage=86400 and warm at 4 PoPs that sit behind one origin shield. At t = 100 s the team purges that URL across the CDN. The four PoPs then get one request each, one after another, at t = 110, 111, 112 and 113 s.
How many requests reach the origin?
Question 4 of 5
A ticket site shows event 51's start time on three pages, all public, s-maxage=3600: /events/51 and /events/51/seats, each sent with Cache-Tag: event-51, and the venue listing /venues/3/events, sent with Cache-Tag: venue-3,listing. The start moves from 19:30 to 20:00, and the team purges the tag event-51. One second later the event and seat pages show 20:00, but fans still see 19:30 on the listing.
Which is the bug?
Question 5 of 5
A payments site publishes /rates/eur-gbp.json through a CDN with Cache-Control: public, max-age=60. Requests are rare: at t = 0, 500, 700, 800 and 950 s. The rate changes at t = 300, and the origin answers 503 from t = 600 to 900. The team wants two things: while the origin is up, nobody gets a copy older than 60 s; while it is down, everybody still gets a rate at most 15 minutes old.
Which one change to the header meets both?
Go deeper
Pro tips
- Default authenticated routes to
Cache-Control: private, no-storeand opt shared ones in deliberately (curriculum pro tip). When: adding any route that reads a session or anAuthorizationheader (steps 2, 5). Verify: with a straypublic, max-age=30, Ben's request with his own bearer token gets Amal's response (ben HIT "amal"), because RFC 9111 §3.5 allows reuse whenpublicis present; withprivate, no-storeeach gets their own. This is the model, not a vendor's CDN. - Give the edge its own lifetime with
CDN-Cache-Control(RFC 9213), for exampleCDN-Cache-Control: max-age=300besideCache-Control: no-cache. When: the edge may keep a copy longer than browsers should (steps 2, 7). Verify: a cache that implements RFC 9213 must ignoreCache-Controlwhen a validCDN-Cache-Controlis present. Status: DOC (RFC 9213 §2.2); not run, andedge.mjsdoes not implement it.
Source
- RFC 9111, HTTP Caching: https://www.rfc-editor.org/rfc/rfc9111 · fetched 2026-09-27. Valid as of 2026-09-27.
- Both say "MAY" and "SHOULD", so a CDN may behave differently. Valid as of 2026-09-27.
- RFC 9213, Targeted HTTP Cache Control (
CDN-Cache-Control): https://www.rfc-editor.org/rfc/rfc9213 · fetched 2026-09-27 (pro tip 2). Valid as of 2026-09-27. - Cloudflare, Cache keys: https://developers.cloudflare.com/cache/how-to/cache-keys/ · fetched 2026-09-27. The default key is the full URL with its query string (plus a few headers such as
Origin); cookies and other headers are not included by default; custom keys "may reduce your cache hit rate". Custom keys offer "Sort query string", and user features: "device_typeclassifies a request asmobile,desktop, ortabletbased on the User Agent", andlang, the first language code fromAccept-Language. Valid as of 2026-09-27. - Cloudflare, Origin Cache Control: https://developers.cloudflare.com/cache/concepts/cache-control/ · fetched 2026-09-27. Documents
s-maxage,private,no-store,stale-while-revalidate(the first request after expiry receives stale content with anUPDATINGstatus while revalidation runs in the background) andstale-if-error, under the heading "s-maxagedisablesstale-while-revalidate". It also says Cloudflare "does not consider vary values in caching decisions" by default, exceptAccept-Encoding, configured Vary rules and images. Valid as of 2026-09-27. - Cloudflare, Purge by cache-tags: https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-tags/ · and Fastly, Working with surrogate keys: https://www.fastly.com/documentation/guides/full-site-delivery/purging/working-with-surrogate-keys/ · fetched 2026-09-27. Cloudflare reads a comma-separated
Cache-Tagheader and removes it before the response reaches visitors; Fastly reads a space-separatedSurrogate-Keyheader, and purging a key purges every object associated with it. Valid as of 2026-09-27. - Cloudflare, Tiered Cache: https://developers.cloudflare.com/cache/how-to/tiered-cache/ · fetched 2026-09-27. A lower tier asks an upper tier; "only the upper-tier can ask the origin for content". Valid as of 2026-09-27.
Related lessons
Checked against the tools and docs as of 2026-09-27.