completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Explain 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.
  • 02
    Raise the hit ratioCount hits, keep tracking tags out of the cache key, and put a shield behind the PoPs.
  • 03
    Cache 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:

Ana and Ben open the shop's page. With no CDN, each goes all the way to the shop's server, a 200 ms round trip away. Ana's page starts to arrive at 806.2 ms. Ben's page starts to arrive at 806.2 ms. With a CDN, a CDN server 20 ms away keeps a copy of the page. Ana came first, with no copy yet, so the CDN server fetched the page from the shop's server, 150 ms away, once. Ben's page came from the copy, at 65.5 ms.No CDNthe pageMint tea shopThe shop's photo of a cup of mint teashop's serverAna806.2 msBen806.2 msWith a CDNthe pageMint tea shopThe shop's photo of a cup of mint teashop's serverAnano copy yetBen65.5 ms200 msa copyMint tea shopThe shop's photo of a cup of mint teaCDN server20 msonce, for Ana150 ms
Bold: when each page started to arrive. Grey: one round trip, there and back. Ana came first, so the CDN server fetched the page once and kept a copy. Ben's page came from that copy. The next section times Ana's visit.

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 page, from far and from nearPop out in the code editor (opens in a new tab)JavaScript
// 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. and From 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:

Real-life analogyCorner shops and one warehouse

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".

The main idea

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:

Playground: a small CDN
What happened
  1. TTFB65.5 ms
  2. FCP116 ms
  3. LCP128 ms
  4. PoP A: /HIT
  5. PoP A: /main.6f1a.cssHIT
  6. PoP A: /hero.jpgHIT
  7. Reached the originnothing
  8. Server-Timing, read by the pagecdn-cache HIT
Try it yourself
CDN, page too, Ben visits: TTFB 65.5 ms

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.

Recorded in Chrome 156 and nginx 1.31.5 on Apple M1 Pro. Round trips: 20 ms from a visitor to a PoP, 200 ms from a visitor to the origin, 150 ms from a PoP to the origin, 120 ms from a PoP to the shield, and 30 ms from the shield to the origin.

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 cachedPop out in the code editor (opens in a new tab)JavaScript
// 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. and Through 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's miss, right after Ana'sPop out in the code editor (opens in a new tab)JavaScript
// 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 prints Ben'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:

Median TTFB of 5 runs. No CDN, Ana: 806.2 ms. No CDN, Ben: 806.2 ms. CDN, files only, Ana: 570.1 ms. CDN, files only, Ben: 418.8 ms. CDN, page too, Ana: 571.2 ms. CDN, page too, Ben: 65.5 ms.No CDN, Ana806.2 msNo CDN, Ben806.2 msCDN, files only, Ana570.1 msCDN, files only, Ben418.8 msCDN, page too, Ana571.2 msCDN, page too, Ben65.5 ms
Shaded: Ben, who came second. With no CDN, both waited the same. Through the CDN, even Ana was quicker, with nothing cached anywhere.

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:

Real-life analogyThe first customer waits

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.

Counting hits at PoP APop out in the code editor (opens in a new tab)JavaScript
// 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:

Reading the cache status in the pageJavaScript
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 serverTiming list for the CDN's metric, cdn-cache.
  • Line 3 sends its description, HIT or MISS, with the rest of the visit's data. sendToAnalytics stands 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."

Real-life analogyOne tea, five names

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:

PoP A's cache settings, in nginxText
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_uri here 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_status holds what the cache did, such as HIT or MISS.

Five visitors then came through PoP A, one after another. Each came from a different campaign link:

Five campaign links, two keysPop out in the code editor (opens in a new tab)JavaScript
// 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. and Key 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:

Five visitors from five campaign links: PoP A's answer for each page, the same in all 5 runs. Key with the query: MISS, MISS, MISS, MISS, MISS. Key without it: MISS, HIT, HIT, HIT, HIT.Visitor 1Visitor 2Visitor 3Visitor 4Visitor 5Key with the queryMISSMISSMISSMISSMISSKey without itMISSHITHITHITHIT
Shaded: a hit, the page from PoP A's cache. Without the query string in the key, Visitor 1's copy served everyone after. With it, every page was a miss.

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:

The hit ratio, with two keysPop out in the code editor (opens in a new tab)JavaScript
// 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%. and Key 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:

Ben at a second PoPPop out in the code editor (opens in a new tab)JavaScript
// 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. and The 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.

Real-life analogyA regional depot

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:

Ben at a second PoP, with a shieldPop out in the code editor (opens in a new tab)JavaScript
// 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. and The 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.

Ben at PoP B, after Ana at PoP A. No shield: PoP B MISS, 3 requests to the origin, TTFB 417.8 ms. With a shield: PoP B MISS, shield HIT, 0 requests to the origin, TTFB 188.7 ms.PoP BShieldOriginNo shield417.8 msMISS3 requestsWith a shield188.7 msMISSHIT0 requests
Shaded: a hit. Crossed out: that setup had no shield. With a shield, PoP B still missed, but the shield had Ana's copy, so nothing reached the origin.

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:

Every path the shop's page took
Where the page came fromWhat happened on the wayTime to first byte
PoP A's cacheA hit. Nothing left the PoP.65.5 ms, for Ben
The shield's cachePoP B missed. The shield had Ana's copy.188.7 ms, for Ben
The shop, on an open linePoP A missed. It asked the shop on the line it had opened for Ana.418.8 ms, for Ben
The shop, on a new linePoP A missed. It had to open a line to the shop first.570.1 ms, for Ana
The shop, with no CDNThree 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 page, checked every time or kept at the edgePop out in the code editor (opens in a new tab)JavaScript
// 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 with max-age=0, s-maxage=60.
  • Each holds what PoP A told Ben's browser in its cdn-cache metric, and his FCP.
  • Lines 6 and 7 print no-cache: PoP A said MISS, and the page painted at 468 ms. and s-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.

Real-life analogySandwiches made in the shop

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:

A greeting made at the edgeJavaScript
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 fetch function. 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 uses guest.
  • Line 4 answers with a new Response, made right there. For https://shop.test/hello?name=Ana, it answers Hello, 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:

  1. Is it personal? Keep it out of the CDN's cache.
  2. Is it the same page under many addresses? Take the extra part out of the cache key.
  3. Do visitors spread over many PoPs, or does the origin struggle with misses? Add a shield.
  4. Is it the same for everyone? Cache it at the edge.
Which change fits?
  • 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
Try it yourself
0 of 8 correct

Sort each case by the change that helps it most.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Exercise 1 · Warm-upBen at another PoP

Ben now visits PoP B, which has never seen the page. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// 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.`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWith a shield

    Ben visits PoP B instead, with a shield behind it. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // 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.`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeBen's LCP with a shield

      In the playground, with a shield, what was Ben's largest contentful paint (LCP), in ms?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeA campaign link

        With the query string in the key, what was Visitor 3's TTFB, in ms?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeA good hit ratio

          web.dev's CDN article names a cache hit ratio that's a good goal for most sites. What is it?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeHolding a page until told

            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?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask how far the request travelled, which cache could answer it, and what made it miss.

              CDNs and the edge quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the first line of this hit ratio example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // 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.

              2. Question 2 of 7What does the second line of the shield example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // 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.

              3. 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.

              4. Question 4 of 7Why can a CDN with more PoPs have a lower hit ratio?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does an origin shield do?

                Choose an answer to see the explanation.

              6. Question 6 of 7Newsletter links add ?utm_source= to every address. What raises the hit ratio?

                Choose an answer to see the explanation.

              7. 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:

              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.

              CompleteFrontend Clear concepts. Working examples.