CDNs and the edge
Serve from close to the user: points of presence, hit ratios, cache keys, and edge logic. A small CDN built from nginx, two points of presence, a shield and a far-away origin, measured in Chrome: misses and hits, campaign links, a second PoP with and without a shield, and the page itself cached at the edge.
- 01Explain where a CDN's speed comes fromA PoP near the visitor makes every hello short. An open line behind it, and a copy in its cache, save the rest of the trip.
- 02Raise the hit ratioCount hits, keep tracking tags out of the cache key, and put a shield behind the PoPs.
- 03Cache the page itselfKeep a page that's the same for everyone at the edge with
s-maxage, and read what the CDN did in your field data.
Serving from close to the visitor
Ana opens the Mint tea shop's page. The shop's own server is far away from her. Every round trip takes 200 ms. A round trip is a message from her browser to the server and the answer back. A millisecond (ms) is a thousandth of a second.
Her page starts to arrive after 806.2 ms. Then Ben opens the same page, in his own browser, and waits just as long. Every visitor makes the same long trip.
What if a copy of the page waited much nearer to them? That's the idea behind a CDN, a content delivery network. A CDN is a set of servers spread out near visitors. Each one keeps copies of a site's files and hands them out.
Here are both ways, with the times the course measured:
Look at the arrows. With no CDN, both trips go all the way to the shop's server. With a CDN, Ben's stops at a server near him, which kept a copy.
Latency vs bandwidth compared a CDN to a tea shop with a branch in every town. HTTP caching in depth called it a shared cache: the office fridge that everyone uses. This lesson opens one up and times it.
Two names first. The shop's own server is the origin: the place every copy comes from. A CDN's servers near the visitors are its points of presence, or PoPs. The course built a small CDN with two of them, PoP A and PoP B. Here is Ben's page two ways: straight from the origin, and from PoP A. PoP A had a copy of it:
// Ben's time to first byte (TTFB), in ms: the median of 5 runs.const straightToTheShop = 806.2;const fromPopA = 65.5; console.log(`Straight to the shop: ${straightToTheShop} ms.`);console.log(`From PoP A, near Ben: ${fromPopA} ms.`);- Line 1 says what the numbers are. Time to first byte, or TTFB, is when the first byte of the page arrived. It's counted from the start of the visit. The navigation timeline took it apart.
- Each time in this lesson is the median of 5 runs: the middle value, once they're sorted.
- Line 2 holds Ben's visit straight to the origin. Line 3 holds his visit to PoP A.
- Lines 5 and 6 print
Straight to the shop: 806.2 ms.andFrom PoP A, near Ben: 65.5 ms.
From PoP A, his page started arriving in less than a tenth of the time. Why it matters: a faster server can't shorten the trip to it. Only a shorter distance can, as Faster server responses found.
How the course built its CDN
The course ran it all on one computer. nginx, a real web server, played every part of the CDN:
- The shop. Its page,
/, links a stylesheet and a photo. The origin takes 200 ms to build the page. The stylesheet and the photo may be kept for a year, as in A caching strategy. - Two PoPs, PoP A and PoP B, each with its own cache. Each is 20 ms from the visitors and 150 ms from the origin.
- A shield: one more cache, near the origin, used in some setups. The section on tiers explains it.
- Ana and Ben, two visitors, each with a new browser and an empty browser cache.
- A small program held every message for its round trip. Every setup ran 5 times, from empty caches.
Think of a chain of shops:
A tea company keeps its stock in one big warehouse, far out of town. It also runs a corner shop on every high street, stocked from the warehouse. Most customers buy from the shop on their own street, in a minute. Only when the shop has run out does it send to the warehouse. Then that customer waits a day.
- In real life: The warehouse, far out of town
- In a CDN: The origin, the shop's own server
- In real life: A corner shop on your street
- In a CDN: A PoP, near the visitor
- In real life: Buying what's on the shelf
- In a CDN: A cache hit: the PoP answers from its copy
- In real life: "We'll get it from the warehouse"
- In a CDN: A cache miss: the PoP asks the origin
Those last two pairs are the words you'll see most. When a PoP has a copy and answers from it, that's a cache hit. When it has none and has to ask the origin, that's a cache miss. nginx writes them in its log as HIT and MISS.
PoPs sit at the edge of the network, nearest the visitors. web.dev calls them "edge caches".
A CDN puts servers near your visitors. On a hit, the visitor never waits for the far-away origin. Even on a miss, the slow start of a connection happens nearby. Your job is to turn more misses into hits. Keep the cache key clean. Put a shield behind the PoPs. And keep the page itself at the edge when it's the same for everyone.
Every visit in this lesson is in this playground. Pick a setup, then a visitor:
- TTFB65.5 ms
- FCP116 ms
- LCP128 ms
- PoP A: /HIT
- PoP A: /main.6f1a.cssHIT
- PoP A: /hero.jpgHIT
- Reached the originnothing
- Server-Timing, read by the pagecdn-cache HIT
This visitor went to PoP A. PoP A had the page, so nothing went on to the origin. Each time is the median of 5 runs, the middle one. What each cache did was the same in every run.
Want to see how a CDN is built inside? See CDN caching and DNS, CDNs, and the edge in the system design course.
Points of presence and anycast
Ben's 65.5 ms came from a hit. But the first visitor to a PoP always misses. Does a CDN help her at all?
Ana was that first visitor, with nothing cached anywhere. PoP A had to ask the origin for every file:
// Ana's first visit, with nothing cached anywhere. Her TTFB in ms: the median of 5 runs.const straightToTheShop = 806.2;const throughPopA = 570.1; console.log(`Straight to the shop: ${straightToTheShop} ms.`);console.log(`Through PoP A, which had to ask the shop: ${throughPopA} ms.`);- Line 1 says this is Ana's first visit. Every cache was empty.
- Line 2 holds her visit straight to the origin. Line 3 holds her visit through PoP A, which missed.
- Lines 5 and 6 print
Straight to the shop: 806.2 ms.andThrough PoP A, which had to ask the shop: 570.1 ms.
Ana was quicker through the CDN, without a single hit. How can a detour beat going straight there?
Counting the round trips
Before a browser can ask for a page, it needs two round trips. TCP connections and slow start showed the first: the TCP hello. It's like waiting for someone to pick up the phone. TLS without the wait showed the second: agreeing on a secret code. The request itself is a third round trip.
- Straight to the origin: three round trips of 200 ms take 600 ms. The shop builds the page in 200 ms. That's 800 ms, close to the recorded 806.2.
- Through PoP A: the three round trips take 20 ms each, so 60 ms. PoP A has no line open to the origin yet. It opens one, a 150 ms round trip, then asks, another 150 ms. The shop builds the page in 200 ms. That's 560 ms, close to the recorded 570.1.
The hellos made the difference. Through the CDN, they crossed the short distance. web.dev: "terminating the new connection as close to the user as possible eliminates unnecessary connection setup costs." Why it matters: a CDN speeds up even the files it can't keep.
The PoP stays on the line
Ben came next. In this setup, the CDN kept the stylesheet and the photo, but not the page. The page was sent with no-cache. That means a cache must check with the origin before each use. So PoP A had to ask the origin for it again:
// Ben came right after Ana. The CDN kept the files but not the page, so PoP A asked the shop again.// TTFB in ms: the median of 5 runs.const ana = 570.1;const ben = 418.8; console.log(`Ana: ${ana} ms. Ben: ${ben} ms.`);console.log(`Ben's page came ${(ana - ben).toFixed(1)} ms sooner.`);- Lines 1 and 2 say what happened. Ben came right after Ana, and PoP A asked the origin for his page too.
- Lines 3 and 4 hold their times.
- Line 6 prints
Ana: 570.1 ms. Ben: 418.8 ms.Line 7 subtracts, and printsBen's page came 151.3 ms sooner.
Both visits missed. Yet Ben's page came about one 150 ms round trip sooner. That's the hello PoP A didn't say again. Like the phone call in TCP connections and slow start, PoP A stayed on the line after Ana's visit. Count it: 60 ms to reach PoP A, 150 ms to ask, and 200 ms to build make 410 ms, close to the recorded 418.8.
A line that's already open is called a warm connection. web.dev: a CDN's trip to the origin "often includes existing, persistent connections with the origin." Persistent means kept open.
When the CDN kept the page too, Ben's visit was the hit from the overview. Here are all three setups:
One address, many PoPs
How does Ana's browser reach a PoP near her? A CDN can give all its PoPs the same address. That's called anycast. A datagram is one packet of data. RFC 4786 defines anycast as making one address "available in multiple, discrete, autonomous locations, such that datagrams sent are routed to one of several available locations." The internet's routers pick the location. RFC 4786 says the choice depends partly on "the point in the network at which the request originates". That's where the visitor is.
It's like one phone number for a chain of taxi firms. Which office answers depends on where you call from. The course's small CDN didn't use anycast. It sent each visitor to a PoP it chose.
Cache hit ratio
Ana filled PoP A's cache, and Ben found it full. Every PoP starts out this way:
A new corner shop opens with empty shelves. The first customer asks for mint tea. So the shop orders a box from the warehouse, and she waits until it comes. The shop puts the rest of the box on its shelf. Everyone who asks after her takes a packet straight away.
- In real life: Empty shelves on opening day
- In a CDN: An empty cache, at the start of every run
- In real life: The first customer, who waits for the order
- In a CDN: Ana: a miss for every file
- In real life: The box kept on the shelf
- In a CDN: The copies PoP A stored during Ana's visit
- In real life: Customers served from the shelf
- In a CDN: Cache hits, like Ben's
web.dev calls this "origin pull". The first time a file is asked for, "the CDN will request it from the origin server and cache the response." A cache fills up one miss at a time.
How often did visitors get the quick answer? That's the cache hit ratio. web.dev defines it as "the number of cache hits divided by the number of total requests during a given time interval." Here is PoP A's ratio, over Ana's visit and Ben's.
// What PoP A did with each request when it kept the page too. The same in all 5 runs.const ana = ["MISS", "MISS", "MISS"]; // the page, the stylesheet and the photoconst ben = ["HIT", "HIT", "HIT"]; const all = [...ana, ...ben];const hits = all.filter((status) => status === "HIT").length;console.log(`${hits} hits out of ${all.length} requests: a hit ratio of ${(hits / all.length) * 100}%.`);- Line 1 says what the lists hold: PoP A's answers, from nginx's log. They were the same in all 5 runs.
- Line 2 holds what PoP A did with Ana's three requests: the page, the stylesheet and the photo. All three missed.
- Line 3 holds Ben's three. All three hit.
- Line 5 puts the six together. Line 6 counts the hits.
- Line 7 prints
3 hits out of 6 requests: a hit ratio of 50%.
Half of PoP A's answers were hits. With more visitors, the ratio climbs, because only the first one misses. Why it matters: every miss sends a visitor on the long trip, and gives the origin work.
Hits in your own field data
web.dev says your CDN should report its hit ratio to you. But what does a miss cost real visitors? For that, you need the hit or miss for each visit. Put it next to the visit's largest contentful paint (LCP). A Server-Timing header can carry it. MDN says it "communicates one or more performance metrics about the request-response cycle to the user agent", the browser.
Each metric in it has a name, and can have a description, called desc. The course's PoPs added one metric to every response: cdn-cache, with nginx's HIT or MISS as its description. When Ben's page came from PoP A's cache, his browser read cdn-cache HIT. A page can read it like this:
const [page] = performance.getEntriesByType("navigation");const cache = page.serverTiming.find((entry) => entry.name === "cdn-cache");sendToAnalytics({ cdnCache: cache?.description ?? "unknown" });- Line 1 gets the page's navigation entry, as in The navigation timeline.
- Line 2 looks through its
serverTiminglist for the CDN's metric,cdn-cache. - Line 3 sends its description,
HITorMISS, with the rest of the visit's data.sendToAnalyticsstands for your own code that sends it, as in Sending performance data.
Now you can split your field data into visits that hit and visits that missed. If the misses are much slower, the next two sections show two ways to raise the ratio.
Cache keys and query strings
The shop runs five campaigns, like a newsletter. Each link adds a tag to the address. That way the shop can count where visitors came from: /?utm_source=campaign1, /?utm_source=campaign2, and so on. The part after the ? is the query string. The page is the same whatever it says.
Does the CDN know that? A cache files each copy under a cache key, and looks it up by that key. A caching strategy showed this. By default, the key holds the whole address, query string and all. Here are two:
- Cloudflare's default key includes the "URI with query string - for example, /logo.jpg?utm_source=newsletter".
- nginx's default key ends in
$request_uri. nginx's docs describe it as the "full original request URI (with arguments)". The arguments are the query string.
So to a CDN, every campaign link is a different page. web.dev: "By default, a CDN would cache example.com/blog and example.com/blog?referral_id=2zjk separately even though they are likely the same underlying resource."
A corner shop keeps a list of what's on its shelf. It's written word for word, as customers asked for it. One customer asks for "mint tea, as in the newsletter". The next asks for "mint tea, as on the poster". The shopkeeper finds no line that matches word for word. So he orders another box from the warehouse, with mint tea already on the shelf.
- In real life: The list of what's on the shelf
- In a CDN: The cache, filed by key
- In real life: "Mint tea, as in the newsletter"
- In a CDN:
/?utm_source=campaign1 - In real life: Matching the list word for word
- In a CDN: A key with the whole query string
- In real life: Reading only "mint tea"
- In a CDN: A key without the query string
A caching strategy had the opposite problem. Its key left out something that mattered: the visitor's cookie. So Ben got a cup with Ana's name on it. Here, the key holds something that doesn't matter. So the same page is stored again and again.
The course tried both keys at PoP A. Here are its cache settings. This one leaves the query string out:
proxy_cache edge;proxy_cache_key $scheme$host$uri;proxy_pass http://origin;proxy_http_version 1.1;proxy_set_header Connection "";proxy_set_header Host $host;add_header Server-Timing "cdn-cache;desc=$upstream_cache_status";- Line 1 turns on the cache, which the course named
edge. - Line 2 sets the key: the scheme, the host, and
$uri, the path without the query string. For the key with the query string, the course used$request_urihere instead. - Line 3 sends a miss on to
origin. That's a group of servers, set up elsewhere in the file. It keeps idle lines to the origin open. - Lines 4 and 5 let those lines stay open between requests, as nginx's docs ask. Line 6 passes on the visitor's host name.
- Line 7 adds the Server-Timing metric from the last section.
$upstream_cache_statusholds what the cache did, such asHITorMISS.
Five visitors then came through PoP A, one after another. Each came from a different campaign link:
// Five visitors came through PoP A, each from a different campaign link: ?utm_source=campaign1 to campaign5.// How many of their 5 pages came from PoP A's cache. The same in all 5 runs.const keyWithTheQuery = 0;const keyWithoutIt = 4; console.log(`Key with the query string: ${keyWithTheQuery} of 5 pages from the cache.`);console.log(`Key without it: ${keyWithoutIt} of 5 pages from the cache.`);- Lines 1 and 2 say what happened. The numbers count pages that came from PoP A's cache.
- Line 3 is the key with the query string. Line 4 is the key without it.
- Lines 6 and 7 print
Key with the query string: 0 of 5 pages from the cache.andKey without it: 4 of 5 pages from the cache.
With the query string in the key, every visitor waited for the origin. Each link was new to PoP A. Without it, Visitor 1 filled the cache, and the other four got a hit. Here is each visitor's page:
The stylesheet and the photo have no query string. So they hit from Visitor 2 on, with either key. Counting them too, here is PoP A's hit ratio:
// All 15 requests PoP A got from the five visitors: 5 pages, 5 stylesheets and 5 photos.const requests = 15;const hitsWithTheQuery = 8;const hitsWithoutIt = 12; const ratio = (hits) => `${((hits / requests) * 100).toFixed(1)}%`;console.log(`Key with the query string: a hit ratio of ${ratio(hitsWithTheQuery)}.`);console.log(`Key without it: a hit ratio of ${ratio(hitsWithoutIt)}.`);- Line 2 counts PoP A's requests: five visitors, three files each.
- Lines 3 and 4 hold the hits with each key, from nginx's log.
- Line 6 turns a number of hits into a percentage.
- Lines 7 and 8 print
Key with the query string: a hit ratio of 53.3%.andKey without it: a hit ratio of 80.0%.
One line of settings took PoP A from 53.3% to 80.0%. And the four extra hits were all pages: the one response the origin takes 200 ms to build.
Which parameters to leave out
Leave out only the parameters that don't change the page, like utm_source. On Cloudflare, a cache rule can exclude them from the key.
Keep every parameter that does change the page, like ?page=2 on a list. Leave that one out, and visitors get the wrong page: Ben's cup again. web.dev warns about caching that isn't precise enough. It "results in incorrect responses being served to users."
Tiered caching and origin shields
So far, Ana and Ben both went to PoP A. A real CDN can have many PoPs, so that more visitors have one nearby. But each PoP has its own cache. What happens when Ben goes to a different one?
In the next setup, Ana visited through PoP A, as before. Then Ben visited through PoP B. Both PoPs could keep the page:
// Ana visited through PoP A. Then Ben visited through PoP B, whose cache was empty.// Ben's TTFB in ms: the median of 5 runs. The requests that reached the shop: the same in every run.const ttfb = 417.8;const toTheShop = 3; console.log(`Ben's page started arriving at ${ttfb} ms.`);console.log(`The shop got ${toTheShop} of his requests.`);- Lines 1 and 2 say what happened, and what the numbers are.
- Line 3 holds Ben's time. Line 4 counts his requests that went on to the origin.
- Lines 6 and 7 print
Ben's page started arriving at 417.8 ms.andThe shop got 3 of his requests.
PoP A had Ana's copies. But PoP B didn't, so all three of Ben's files came from the origin. His wait was close to his miss at PoP A, 418.8 ms. In the course's CDN, both PoPs ran inside one nginx. So PoP B could use the line PoP A had opened, and skip the hello.
Latency is the time a message takes to arrive. web.dev says CDNs with many PoPs "can deliver lower latency but may experience lower cache hit ratios as a result of traffic being split across more caches." More PoPs put a shop nearer every visitor. They also give the CDN more shelves to fill.
The corner shops don't all order from the warehouse. A regional depot sits between them and the warehouse. When a shop runs out, it asks the depot. Only the depot orders from the warehouse. So once one shop has asked for mint tea, the depot has a box. The next shop to run out gets it from nearby.
- In real life: Many corner shops, each with its own shelf
- In a CDN: Many PoPs, each with its own cache
- In real life: The regional depot
- In a CDN: The shield: a cache the PoPs ask on a miss
- In real life: Only the depot orders from the warehouse
- In a CDN: Only the shield asks the origin
- In real life: The next shop's box comes from the depot
- In a CDN: Ben's miss at PoP B, answered by the shield
That depot is an origin shield. CloudFront, Amazon's CDN, describes its Origin Shield as "an additional layer in the CloudFront caching infrastructure".
Cloudflare calls the idea Tiered Cache. Its lower tiers are generally the data centres nearest the visitors. Its upper tiers sit behind them. When a lower tier misses, its docs say: "the lower-tier must ask an upper-tier to see if it has the content. If the upper-tier does not have the content, only the upper-tier can ask the origin for content." That's the shield.
The course added a shield 30 ms from the origin, and 120 ms from each PoP. Then it ran the same two visits again:
// The same two visits, with a shield between the PoPs and the shop.const ttfb = 188.7;const toTheShop = 0; console.log(`Ben's page started arriving at ${ttfb} ms.`);console.log(`The shop got ${toTheShop} of his requests.`);- Line 1 says what changed: a shield between the PoPs and the origin.
- Lines 2 and 3 hold Ben's time, and his requests that reached the origin.
- Lines 5 and 6 print
Ben's page started arriving at 188.7 ms.andThe shop got 0 of his requests.
PoP B still missed. But this time it asked the shield. And the shield had Ana's copies of all three files. Ben's page came in less than half the time, and the origin heard nothing.
Two things made it quick. The shield was nearer than the origin: 120 ms away, not 150. And it already had the page, so nobody waited 200 ms for the shop to build it.
A shield costs one more stop. For Ana, PoP A asked the shield, and the shield asked the origin. Her page started arriving at 573.9 ms, against 571.2 ms with no shield. The stop cost little, because 120 ms plus 30 ms makes 150. web.dev calls tiers a trade of "slightly greater latency for a higher likelihood that the resource can be served from a CDN cache."
The origin gains too. Over both visits, it got six requests with no shield, and three with one. Cloudflare says tiers reduce "origin load" by "limiting the number of data centers that can ask the origin for content".
Every path a page can take
Here is every path the page took through the CDN. Read it from the top, quickest first. Each row is one path:
| Where the page came from | What happened on the way | Time to first byte |
|---|---|---|
| PoP A's cache | A hit. Nothing left the PoP. | 65.5 ms, for Ben |
| The shield's cache | PoP B missed. The shield had Ana's copy. | 188.7 ms, for Ben |
| The shop, on an open line | PoP A missed. It asked the shop on the line it had opened for Ana. | 418.8 ms, for Ben |
| The shop, on a new line | PoP A missed. It had to open a line to the shop first. | 570.1 ms, for Ana |
| The shop, with no CDN | Three long round trips, then the shop built the page. | 806.2 ms, for both |
Lower rows lost more time on long trips, and on the shop building the page.
Caching HTML at the edge
Ben's quickest visit, 65.5 ms, came when PoP A kept the page itself. That's harder than keeping a file with a hash in its name. A hash is a short code worked out from a file's content, so the name changes when the file does. The page's address never changes, but its content can.
A caching strategy checked the page on every visit, with no-cache. For a page that's the same for everyone, there's a middle way: s-maxage. It's the office fridge's own rule from HTTP caching in depth. The shop sent Cache-Control: max-age=0, s-maxage=60:
max-age=0: a browser's copy is stale at once, too old to use without checking. So the browser checks on every visit.s-maxage=60: a shared cache, like a PoP, may keep the page for 60 seconds. MDN says browsers' own caches ignore it.
// Ben's visit to PoP A, right after Ana's. What PoP A told his browser, the same in all 5 runs,// and his first contentful paint (FCP) in ms, the median of 5 runs.const noCache = { status: "MISS", fcp: 468 };const sMaxage = { status: "HIT", fcp: 116 }; console.log(`no-cache: PoP A said ${noCache.status}, and the page painted at ${noCache.fcp} ms.`);console.log(`s-maxage=60: PoP A said ${sMaxage.status}, and the page painted at ${sMaxage.fcp} ms.`);- Lines 1 and 2 say what the numbers are. First contentful paint, or FCP, is when the first text or image appeared on Ben's screen.
- Line 3 is the page sent with
no-cache. Line 4 is the page sent withmax-age=0, s-maxage=60. - Each holds what PoP A told Ben's browser in its
cdn-cachemetric, and his FCP. - Lines 6 and 7 print
no-cache: PoP A said MISS, and the page painted at 468 ms.ands-maxage=60: PoP A said HIT, and the page painted at 116 ms.
With the page at the edge, Ben saw the shop at 116 ms instead of 468. His whole visit stayed at PoP A: the page, the stylesheet and the photo were all hits. web.dev says a CDN improves LCP by "reducing TTFB in connection setup and in caching the document". Ben's visit had both.
When the page changes
A page kept at the edge can go out of date. A caching strategy showed the fix: a purge as a step of every deploy. It's like collecting old menus from the tables. A purge tells the CDN to drop its copies.
With purging, a changing page can even stay at the edge for a long time. A TTL, time to live, is how long a copy may be kept. web.dev: "cache dynamic content using a long TTL, then purge the resource whenever it is updated."
Personal pages are different. Never keep one at the edge. web.dev: private resources "should not be cached by a CDN." Mark them private. A caching strategy did that with its greeting.
Small jobs at the edge
A personal page can't be shared. But some work can still happen near the visitor. web.dev lists "edge computing" among the features CDNs commonly offer: running your own code at the PoPs.
Some corner shops make sandwiches to order, right behind the counter. Your sandwich is made just for you, a minute after you ask. Nobody phones the warehouse, because the shop already has the bread and the fillings.
- In real life: The counter where sandwiches are made
- In a CDN: A PoP that runs your code
- In real life: A sandwich made for one customer
- In a CDN: A response built for one request
- In real life: Nobody phones the warehouse
- In a CDN: No trip to the origin
Cloudflare calls its version Workers. Its docs say that rather than running "on a centralized server", "Workers functions run on Cloudflare's global network". Here is a small one, in the shape Cloudflare's docs use:
export default { async fetch(request) { const name = new URL(request.url).searchParams.get("name") ?? "guest"; return new Response(`Hello, ${name}!`); },};- Line 1 hands Cloudflare an object.
- Line 2 is its
fetchfunction. Cloudflare's docs say when it runs. A request that reaches one of its data centres calls this function, with the request. - Line 3 reads the name from the address, like
?name=Ana, or usesguest. - Line 4 answers with a new
Response, made right there. Forhttps://shop.test/hello?name=Ana, it answersHello, Ana!
The greeting is personal, so no cache could share it. But it's made at the PoP, so it never makes the long trip. If the function fetches something from the origin, that request does. The course didn't time an edge function, so this lesson has no number for one.
Which change fits?
Each case below needs one change more than any other. Ask these questions, in order:
- Is it personal? Keep it out of the CDN's cache.
- Is it the same page under many addresses? Take the extra part out of the cache key.
- Do visitors spread over many PoPs, or does the origin struggle with misses? Add a shield.
- Is it the same for everyone? Cache it at the edge.
- A home page that's the same for every visitor
- An article page that changes now and then
?utm_source=on newsletter links?ref=on a partner's links- Visitors spread over many PoPs, each missing once
- An origin that struggles when many PoPs miss at once
- A page that shows the visitor's basket
- An API that answers with the visitor's own orders
Sort each case by the change that helps it most.
Common mix-ups
- "A CDN only helps files it has cached." No. Ana's first visit missed everywhere, and her page still started arriving at 570.1 ms, not 806.2. Her hellos happened near her.
- "More PoPs always mean more hits." No. Each PoP has its own cache. Ben's first visit to PoP B missed, though PoP A had the page.
- "A longer max-age fixes a low hit ratio." No. When every campaign link has its own key, each one misses the first time, however long the copies are kept.
- "A CDN can't cache HTML." No. With
s-maxage=60, Ben's page was a hit at PoP A, and painted at 116 ms. - "A shield is one more stop, so it's slower." No. Ben's miss at PoP B took 188.7 ms with a shield, and 417.8 ms without one. Ana's first visit was only a little slower: 573.9 ms, against 571.2.
- "An edge function is a kind of cache." No. It builds a new answer for each request. It's quick because it runs near the visitor, not because it kept a copy.
Practice exercises
Answer first, then use the hints.
Ben now visits PoP B, which has never seen the page. What does the last line print?
// Ben's time to first byte (TTFB), in ms: the median of 5 runs.
const straightToTheShop = 806.2;
const fromPopA = 65.5;
const fromPopB = 417.8;
console.log(`Straight to the shop: ${straightToTheShop} ms.`);
console.log(`From PoP A, near Ben: ${fromPopA} ms.`);
console.log(`From PoP B, whose cache was empty: ${fromPopB} ms.`);From PoP B, whose cache was empty: 417.8 ms. PoP B missed, so it had to ask the origin for the page. That's close to Ben's miss at PoP A, 418.8 ms.
Ben visits PoP B instead, with a shield behind it. What does the last line print?
// Ben's visit, right after Ana's. What his PoP told his browser, the same in all 5 runs,
// and his first contentful paint (FCP) in ms, the median of 5 runs.
const noCache = { status: "MISS", fcp: 468 };
const sMaxage = { status: "HIT", fcp: 116 };
const atPopBWithAShield = { status: "MISS", fcp: 360 };
console.log(`no-cache: PoP A said ${noCache.status}, and the page painted at ${noCache.fcp} ms.`);
console.log(`s-maxage=60: PoP A said ${sMaxage.status}, and the page painted at ${sMaxage.fcp} ms.`);
console.log(`s-maxage=60, at PoP B with a shield: PoP B said ${atPopBWithAShield.status}, and the page painted at ${atPopBWithAShield.fcp} ms.`);s-maxage=60, at PoP B with a shield: PoP B said MISS, and the page painted at 360 ms. PoP B had no copy, so its cdn-cache said MISS. The shield had the page, so nothing went on to the origin. Ben's browser also read the shield's own metric, shield-cache, which said HIT.
In the playground, with a shield, what was Ben's largest contentful paint (LCP), in ms?
364 ms. The shield answered all three of Ben's files, so nothing waited for the origin.
With the query string in the key, what was Visitor 3's TTFB, in ms?
418.4 ms. Visitor 3's link was new to PoP A, so the page was a miss, though PoP A already had the same page under two other keys.
web.dev's CDN article names a cache hit ratio that's a good goal for most sites. What is it?
90%. web.dev: "A CHR of 90% is a good goal for most sites."
web.dev describes keeping a changing page at the edge for a long time, and purging it whenever it changes. What does it say this is sometimes called?
Hold-till-told caching. The CDN holds the page for a long time, until you tell it, by purging, that the page has changed.
Check your understanding
For each question, ask how far the request travelled, which cache could answer it, and what made it miss.
Question 1 of 7What does the first line of this hit ratio example print?
Read the code, then predict// All 15 requests PoP A got from the five visitors: 5 pages, 5 stylesheets and 5 photos. const requests = 15; const hitsWithTheQuery = 8; const hitsWithoutIt = 12; const ratio = (hits) => `${((hits / requests) * 100).toFixed(1)}%`; console.log(`Key with the query string: a hit ratio of ${ratio(hitsWithTheQuery)}.`); console.log(`Key without it: a hit ratio of ${ratio(hitsWithoutIt)}.`);Choose an answer to see the explanation.
Question 2 of 7What does the second line of the shield example print?
Read the code, then predict// The same two visits, with a shield between the PoPs and the shop. const ttfb = 188.7; const toTheShop = 0; console.log(`Ben's page started arriving at ${ttfb} ms.`); console.log(`The shop got ${toTheShop} of his requests.`);Choose an answer to see the explanation.
Question 3 of 7Ana's first visit missed at PoP A, yet her page still came sooner than with no CDN. Why?
Choose an answer to see the explanation.
Question 4 of 7Why can a CDN with more PoPs have a lower hit ratio?
Choose an answer to see the explanation.
Question 5 of 7What does an origin shield do?
Choose an answer to see the explanation.
Question 6 of 7Newsletter links add
?utm_source=to every address. What raises the hit ratio?Choose an answer to see the explanation.
Question 7 of 7What is anycast?
Choose an answer to see the explanation.
Key takeaways
- A CDN puts servers, called PoPs, near your visitors. On a hit, nobody waits for the far-away origin.
- Even a miss is quicker. The hellos happen near the visitor, and the PoP keeps its line to the origin open.
- The hit ratio is hits out of all requests. Read the CDN's cache status from Server-Timing to split your field data.
- Keep tracking parameters out of the cache key, and keep every parameter that changes the page in it.
- A shield turns a miss at a new PoP into a nearby hit, and keeps requests off the origin.
- Keep a page that's the same for everyone at the edge with
s-maxage, and purge it when it changes. - An edge function runs your own code at the PoP, so an answer made for one visitor skips the trip to the origin.
Remember the one-liner.
A CDN is a corner shop near every visitor: keep its shelves stocked, label them simply, and put a depot behind them.
Sources
The facts on this page come from these standards and docs:
- Content delivery networks (CDNs), web.dev.
- RFC 4786: Operation of Anycast Services, the IETF.
- Server-Timing header, MDN.
- Module ngx_http_upstream_module, nginx.
- Cache keys, Cloudflare.
- Module ngx_http_proxy_module, nginx.
- Module ngx_http_core_module, nginx.
- Tiered Cache, Cloudflare.
- Use Amazon CloudFront Origin Shield, Amazon Web Services.
- Cache-Control header, MDN.
- How Workers works, Cloudflare.
Coming next: gzip, Brotli, and Zstandard, also in Fast servers, caches, and networks. Every byte that does travel can be made smaller, and web.dev notes that "CDNs can compress both cacheable and non-cacheable resources." The next lesson compares the formats and their levels.