completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

A caching strategy

Decide how long to cache HTML, assets, and API responses, and how to change them safely. A shop with a page, a stylesheet and a price API, recorded in Chrome over a real round trip, straight to the server and through nginx as a CDN: three policies, stale-while-revalidate, stale-if-error, private and public, a deploy, and Clear-Site-Data.

By the end, you can
  • 01
    Give each kind of response its own ruleCheck the page on every visit, keep files with a hash in their name for a year, and let data be as old as it safely can.
  • 02
    Use old copies on purposeShow a stale copy while a new one is fetched, and keep a page working when the server fails, once you've checked what your CDN does.
  • 03
    Change things safelyKeep personal pages out of shared caches, refresh the CDN as a step of every deploy, and clear a browser's cache at logout.

One shop, three kinds of response

Ana opens the Mint tea shop's page. It shows a heading, a line that greets her, and the price of Moroccan mint tea: £4.50. A moment later she opens it again. This time it appears almost at once.

Then the shop changes. It raises the price to £5.25 and gives its heading a new colour. Putting a new version of a site live like this is called a deploy. Ana comes back once more. Does she see the new price?

The course built this shop and timed it in Chrome. First, the shop told browsers to keep every response for an hour:

Keep everything for an hourPop out in the code editor (opens in a new tab)JavaScript
// The shop told browsers to keep every response for an hour: max-age=3600.// paintedAt is when the first words appeared, in ms (the median of 5 runs).const secondVisit = { paintedAt: 36, price: "4.50" };const afterDeploy = { price: "4.50", design: "old" }; console.log(`Second visit: painted at ${secondVisit.paintedAt} ms, showing £${secondVisit.price}.`);console.log(`After the deploy: £${afterDeploy.price}, with the ${afterDeploy.design} design.`);
  • Line 1 says what the shop sent. max-age=3600 means a copy stays fresh for 3,600 seconds, one hour. While a copy is fresh, the browser uses it without asking.
  • Line 2 explains paintedAt: the moment the first words appeared on screen. That moment is called the first contentful paint, or FCP. It's counted in milliseconds (ms) from the start of the visit.
  • Like every time in this lesson, 36 is the median of 5 runs: the middle value, once they're sorted.
  • Line 3 holds Ana's second visit. Line 4 holds her visit after the deploy.
  • Lines 6 and 7 print Second visit: painted at 36 ms, showing £4.50. and After the deploy: £4.50, with the old design.

Here are Ana's three visits side by side, next to the page the shop sent after the deploy:

Ana's three visits while the shop sent max-age=3600 with everything, and the page the shop sent after the deploy. First visit: painted at 628 ms, £4.50, old design. The shop's log: / 200, /main.css 200, /api/price 200. Second visit: painted at 36 ms, £4.50, old design. The shop's log: empty. After the deploy: painted at 36 ms, £4.50, old design. The shop's log: empty. The shop now, for a new visitor with nothing cached: £5.25, new design.First visitpainted at 628 msThe shop's page: Mint tea shop, Hello, guest, Moroccan mint: £4.50£4.50old designthe shop's log/ 200/main.css 200/api/price 200Second visitpainted at 36 msThe shop's page: Mint tea shop, Hello, guest, Moroccan mint: £4.50£4.50old designthe shop's logemptyAfter the deploypainted at 36 msThe shop's page: Mint tea shop, Hello, guest, Moroccan mint: £4.50£4.50old designthe shop's logemptyThe shop nowfor a new visitorThe shop's page: Mint tea shop, Hello, guest, Moroccan mint: £5.25£5.25new design
Ana's three visits, and the page the shop sent after the deploy. Her second and third visits asked the shop nothing, so its log stayed empty. Both were fast, and the third still showed the old price and design. In red: out of date. The screens are real frames Chrome captured in a separate run of the same visits. The last is a new visitor's, with nothing cached. The times are the medians of the lesson's 5 runs.

The second visit painted at 36 ms, less than a twentieth of a second. The visit after the deploy was wrong: the price had gone up, but Ana still saw £4.50. Both results have one cause. For that hour, her browser didn't ask the shop anything. The shop's log for both visits is empty.

So each time a visitor comes back, two things matter for every response:

  • How fast can they get it? Fastest is a copy from their own cache, with no question to the shop.
  • How soon do they see a change? Soonest is asking the shop every time.

The two pull in opposite directions. A caching strategy is your answer to both, for each kind of response your site sends. HTTP caching in depth explained each header on its own. This lesson puts them together for one whole shop. For the same problem across a whole system, see Cache invalidation in the system design course.

How the course timed it

  • The shop lives at shop.test. Its page is HTML, and it links one stylesheet. A small script on the page fetches the price from an API: an address that answers with data instead of a page.
  • So every visit needs three responses: the page, the stylesheet and the price.
  • The shop's own server is called the origin. It takes 200 ms to build the page or to look up the price.
  • A small program between Chrome and the servers held every message on its way. So each round trip, a message there and its answer back, took 100 ms.
  • Some visits went straight to the origin. Others went through nginx, a real web server, set up as a CDN. A CDN, a content delivery network, sits between visitors and the origin. It keeps copies of responses and hands them to every visitor who asks.
  • Chrome made each set of visits 5 times. Every run started with nothing kept anywhere. Chrome waited 2 seconds between visits.
  • Except in the section on private, nobody is logged in, so the page greets each visitor as "guest".

So copies can sit in two kinds of cache. Each visitor's browser keeps its own, for that visitor only. The CDN keeps one set, shared by every visitor who comes through it.

Different kinds of response change in different ways, so they need different rules. A bookshop already works like this:

Real-life analogyA bookshop's door sign and its books

A bookshop has a sign on its door with this week's opening hours. The hours can change at any time, so regulars read the sign every time they arrive. The books inside are different. Each edition has its number printed on the cover. The shop can keep the second edition on its shelf for years, because a third edition would come with a new number.

In real life: The sign on the door, read on every visit
In HTTP caching: The page, sent with no-cache: checked with the shop on every visit
In real life: The edition number printed on a book's cover
In HTTP caching: A hash in a file's name, like main.e6141c36.css
In real life: An edition that stays on the shelf for years
In HTTP caching: max-age=31536000, immutable: keep the file for a year
In real life: A new edition, with a new number
In HTTP caching: A changed file, with a new name
The main idea

A caching strategy gives each kind of response its own rule. Check the page on every visit, so a deploy shows at once. Keep files with a hash in their name for a year, because a change gives them a new name. Let data be as old as your visitors can accept. Keep anything personal out of shared caches. And make clearing the CDN a step of every deploy.

Every visit in this lesson is in this playground. Pick a rule the shop used, then a visit. The rows show what Ana saw, and which servers were asked.

Playground: one shop, many policies
What happened
  1. GreetingHello, guest
  2. Price£4.50, shown at 827.3 ms
  3. Designold
  4. Origin/ 304
  5. Origin/api/price 304
Try it yourself
Each type its own way, repeat visit: FCP 548 ms

The shop sent Cache-Control: no-cache with the page, max-age=31536000, immutable with the stylesheet, and no-cache with the price. Visits went straight to the shop. In all 5 runs, the page showed the same thing and the servers were asked the same things. Times are the median of the 5 runs.

Recorded in Chrome 156 and nginx 1.31.5 on Apple M1 Pro, over HTTP/2. A small proxy held every message for half of a 100 ms round trip.

HTML vs versioned assets vs API data

Keeping everything for an hour was fast, and wrong. The course tried two other rules on the same visits: check everything, every time, and a different rule for each kind of response.

Check everything, every time

no-cache doesn't mean "don't keep it". It means: keep it, but ask the shop before each use. HTTP caching in depth compared that to phoning the shop to check. If nothing has changed, the shop answers 304 Not Modified: a short reply with no body. The browser then uses its own copy.

Check everything, every timePop out in the code editor (opens in a new tab)JavaScript
// Now the shop sends no-cache with every response: "ask me before you use your copy".// paintedAt is when the first words appeared, in ms (the median of 5 runs).const secondVisit = { paintedAt: 636, price: "4.50" };const afterDeploy = { price: "5.25", design: "new" }; console.log(`Second visit: painted at ${secondVisit.paintedAt} ms, showing £${secondVisit.price}.`);console.log(`After the deploy: £${afterDeploy.price}, with the ${afterDeploy.design} design.`);
  • Line 1 gives the new rule: no-cache on the page, the stylesheet and the price.
  • Lines 3 and 4 hold the same two visits as before.
  • It prints Second visit: painted at 636 ms, showing £4.50. and After the deploy: £5.25, with the new design.

After the deploy, Ana saw the new price and the new design at once. But her second visit painted at 636 ms, not 36. On that visit the shop was asked three times, once for each response. It answered 304 every time.

No file was downloaded again, yet each question needed a round trip. The page's question also waited while the shop built the page. This shop needs 200 ms for that, even to answer 304: it builds the page to see whether it changed.

A rule for each kind of response

Now the mixed rule. The page and the price are still checked on every visit. The stylesheet is different: its name holds a hash, and it's kept for a year.

Each type its own wayPop out in the code editor (opens in a new tab)JavaScript
// The page and the price send no-cache. The stylesheet has a hash in its name,// and sends max-age=31536000, immutable: "keep me for a year, and never ask".const secondVisit = { paintedAt: 548, price: "4.50" };const afterDeploy = { price: "5.25", design: "new" }; console.log(`Second visit: painted at ${secondVisit.paintedAt} ms, showing £${secondVisit.price}.`);console.log(`After the deploy: £${afterDeploy.price}, with the ${afterDeploy.design} design.`);
  • Lines 1 and 2 give the rules. The stylesheet sends max-age=31536000, immutable. 31,536,000 seconds is 365 days.
  • immutable says the file won't change while it's fresh. It tells the browser not to check it, even on a reload.
  • Lines 3 and 4 hold the same two visits.
  • It prints Second visit: painted at 548 ms, showing £4.50. and After the deploy: £5.25, with the new design.

This time the shop was asked twice on the second visit: for the page and for the price. The stylesheet came from Ana's cache with no question at all. Her page painted at 548 ms, 88 ms sooner than when everything was checked.

Those 88 ms are close to one 100 ms round trip: the stylesheet's question. The browser doesn't paint until it has the stylesheet, as Render-blocking and parser-blocking resources showed. So that one round trip held back the paint.

And after the deploy, Ana saw the new price and the new design. Here are all three rules side by side:

When Ana's second visit painted, the median of 5 runs, and what her visit after the deploy showed. Everything for an hour: painted at 36 ms; after the deploy, £4.50, old design. Everything no-cache: painted at 636 ms; after the deploy, £5.25, new design. Each type its own way: painted at 548 ms; after the deploy, £5.25, new design.After the deployEverything for an hour36 ms£4.50, old designEverything no-cache636 ms£5.25, new designEach type its own way548 ms£5.25, new design
Each bar shows how soon Ana's second visit painted. Each card shows what she saw after the deploy. A shaded card was out of date.

The mixed rule was always up to date, like checking everything. And it skipped the one question that held back the first paint. web.dev suggests the same starting point: no-cache "for resources that should be revalidated with the server before every use", and max-age=31536000 "for versioned resources."

Why the page is checked on every visit

The page is the bookshop's door sign. Two things make it different from every other file:

  • Its address can't change. People type it, and links point at it. The shop's page is /, before and after every deploy.
  • It names every other file the visit needs. An old copy of the page would ask for the old stylesheet, too.

So the page has to be checked every time. An ETag keeps that check cheap. It's a short label the shop puts on each version, like the batch number on a milk carton in HTTP caching in depth. If Ana's copy has the current label, the answer is a 304.

Why a hashed file can be kept for a year

A hash is a short code worked out from a file's content. Change one character in the file, and the hash changes. Build tools put it into the file's name. webpack calls that name part [contenthash], and its docs say: "When the asset's content changes, [contenthash] will change as well."

The shop's stylesheet worked the same way. Before the deploy, it was called main.e6141c36.css. The deploy changed one colour in it, and the new file was main.e29a8f41.css. The new page linked the new name. Ana's browser had never seen that name, so it fetched it. Nobody asked for the old name again.

That's the edition number on a book's cover. A file at one name never changes, so it can sit in the cache for a year. MDN calls this cache busting: "new content should have a new URL, and caches aren't reused." It only works because the page, which names the files, is checked on every visit.

One change, one new file

The hash comes from the whole file. That matters for JavaScript. Suppose a site keeps all of it in one file: its own code and the libraries it uses. Fix one typo in its own code, and the whole file gets a new name. Every returning visitor downloads all of it again, libraries too.

Real-life analogyA ring binder, not a bound book

A café keeps its recipes in a bound book. To fix one recipe, it has to print a whole new book. The café next door keeps its recipes in a ring binder. To fix one, it prints a single page and clips it in. Every other page stays where it was.

In real life: A recipe book, bound in one piece
In HTTP caching: One JavaScript file for the whole site
In real life: Printing a whole new book to fix one recipe
In HTTP caching: One change gives the whole file a new hash, so everyone downloads all of it
In real life: The pages of a ring binder
In HTTP caching: Several files, each with its own hash
In real life: Printing one new page and clipping it in
In HTTP caching: Only the changed file gets a new name

webpack's caching guide suggests moving libraries "such as lodash or react" into a file of their own, "as they are less likely to change than our local source code."

And the price?

The price is data from an API. Like the page, it lives at one fixed address, /api/price, so it can't get a new name when it changes. With no-cache, Ana always saw the right price, for one question per visit.

So for API data, ask: how old may the answer be? If it must be right, use no-cache. If a slightly old answer is fine, the next section has a faster choice.

stale-while-revalidate and stale-if-error

A stored copy is fresh until its max-age runs out, like milk before its best-before date. After that, it's stale. Normally a browser won't use a stale copy until it has checked with the server.

Each instruction inside a Cache-Control header, like max-age=60, is called a directive. Two more directives let a cache use a stale copy on purpose:

  • stale-while-revalidate: use the stale copy now, and check with the server in the background.
  • stale-if-error: use the stale copy if the server fails.

Both come from RFC 5861, a short standard about stale copies.

Old now, new next time

Real-life analogyThe café's newspaper

A café keeps a newspaper on the counter for its customers. When you sit down, you read the copy that's there, even if it's an hour old. Meanwhile, the owner sends someone out for the latest edition. The next customer reads the new one.

In real life: Reading the paper that's already on the counter
In HTTP caching: Using the stale copy at once
In real life: Someone fetching the latest edition meanwhile
In HTTP caching: Checking with the server in the background
In real life: The next customer reading the new edition
In HTTP caching: The next visit getting the new response
In real life: An hour old is fine; a week old isn't
In HTTP caching: stale-while-revalidate=60: up to 60 seconds past stale

That's stale-while-revalidate. RFC 5861 says it lets a cache "immediately return a stale response while it revalidates it in the background." To revalidate is to ask the server whether a copy is still good. Doing it in the background hides the wait "both in the network and on the server" from the visitor.

The shop sent max-age=1, stale-while-revalidate=60 with the price. Read it as a timeline:

  • For 1 second, the copy is fresh. The browser uses it and asks nobody.
  • From 1 to 61 seconds, it's stale, but the browser still uses it at once. At the same time, it asks the shop in the background.
  • After 61 seconds, it's too old. In web.dev's words, "the stale response isn't used at all", and the page waits for the server as usual.

The course waited 2 seconds between visits, so the price was always stale by the next one. The shop raised the price just before Ana's second visit:

The old price at once, the new one next timePop out in the code editor (opens in a new tab)JavaScript
// The price sends max-age=1, stale-while-revalidate=60. It rose to £5.25 just before visit 2.const visits = [  { visit: "Second visit", price: "4.50", shownAt: 640.1, transferSize: 0 },  { visit: "Third visit", price: "5.25", shownAt: 524.4, transferSize: 0 },]; for (const { visit, price, shownAt, transferSize } of visits) {  const from = transferSize === 0 ? "from the cache" : "after asking the shop";  console.log(`${visit}: £${price} at ${shownAt} ms, ${from}.`);}
  • Line 1 gives the rule, and says when the price changed.
  • Lines 3 and 4 hold two visits. shownAt is when the price appeared on the page, in ms: the median of 5 runs.
  • transferSize comes from Resource Timing, as in HTTP caching in depth. It's 0 when the page's request was answered from the cache.
  • Line 8 turns transferSize into words. Line 9 prints one line per visit.
  • It prints Second visit: £4.50 at 640.1 ms, from the cache. and Third visit: £5.25 at 524.4 ms, from the cache.

On the second visit, Ana saw the old price, at once. But the shop's log shows a request for the price on that same visit, answered 200. That was the check in the background. Its answer, £5.25, replaced the copy in her cache.

So the third visit showed £5.25, again straight from the cache. Its own background check got a 304, because the price hadn't changed again.

A no-cache price can't do this: it has to wait for the shop before it appears. Exercise 1 puts the two side by side. The cost of stale-while-revalidate is one visit with the old answer. So use it where a slightly old answer does no harm, like a list of popular teas. Keep no-cache where an old answer is wrong.

When the server fails

stale-if-error is for a worse moment: the server is broken. RFC 5861 says it lets a cache return a stale response when there's an error, "rather than returning a 'hard' error." MDN lists what counts as an error: "any response with a status code of 500, 502, 503, or 504."

A 500 is the server saying "something went wrong on my side". Think of a station whose live departure board breaks. The printed timetable on the wall is older, but it still tells you when your train should leave.

Notice the standard's exact words: a stale copy "MAY be used". It's allowed, not required. So the course broke the price API before Ana's second visit, and watched who used the old copy. First, the API answered 500:

The API answers 500Pop out in the code editor (opens in a new tab)JavaScript
// The price sends max-age=1, stale-if-error=60. Then the price API breaks and answers 500.// What Ana's next visit showed, the same in all 5 runs.const shown = {  "Straight to the shop": "unavailable",  "Through the CDN": "unavailable",}; for (const [route, price] of Object.entries(shown)) console.log(`${route}: the price was ${price}.`);
  • Line 1 gives the rule, and how the API broke.
  • Lines 3 to 6 hold what Ana saw: once straight from the shop, once through the CDN.
  • It prints Straight to the shop: the price was unavailable. and Through the CDN: the price was unavailable.

Chrome showed the error. So did nginx. Its log says EXPIRED: its copy of the price was stale, so it asked the API, got the 500, and passed it on. Then the course tried two more setups through the CDN:

The CDN uses its old copyPop out in the code editor (opens in a new tab)JavaScript
// Through the CDN again, in two more setups. What Ana saw, the same in all 5 runs.const shown = {  "The API stopped answering": "4.50",  "The API answered 500, and nginx was set to use old copies": "4.50",}; for (const [setup, price] of Object.entries(shown)) console.log(`${setup}: £${price}, the old copy.`);
  • Line 3 is an API that stopped answering at all: it dropped the connection.
  • Line 4 is the same 500 as before, but this time nginx was set to use old copies on a 500.
  • It prints The API stopped answering: £4.50, the old copy. and The API answered 500, and nginx was set to use old copies: £4.50, the old copy.

This time nginx's log says STALE: it gave Ana its old copy. nginx's docs say the header "permits using a stale cached response in case of an error." In the recording, a dropped connection counted as an error, and a 500 didn't.

For a 500, nginx needed its own setting, proxy_cache_use_stale. It "Determines in which cases a stale cached response can be used." The course turned it on for errors, timeouts and http_500.

The price sent max-age=1, stale-if-error=60, then its API failed. What Ana's next visit showed, the same in all 5 runs. Straight to the shop, a 500: Price unavailable. Through the CDN, a 500: Price unavailable. Through the CDN, no answer: £4.50, the old copy. CDN set to use old copies, a 500: £4.50, the old copy.Straight to the shop, a 500Price unavailableThrough the CDN, a 500Price unavailableThrough the CDN, no answer£4.50, the old copyCDN set to use old copies, a 500£4.50, the old copy
Four ways the price API failed, and what Ana saw. A shaded card means the CDN's old copy kept the price on the page.

So sending the header isn't enough. Check what your CDN does with it, and turn on its own setting if you need it.

private vs public

So far there was one visitor. Now there are two: Ana and Ben. HTTP caching in depth compared the two kinds of cache to fridges: your own fridge at home, and the office fridge everyone shares.

  • A browser's cache is a private cache. It keeps copies for one person.
  • A CDN is a shared cache. RFC 9111, the HTTP caching standard, calls it "a cache that stores responses for reuse by more than one user."

Two directives say which caches may keep a response:

  • public: any cache may keep it, shared ones too. MDN: it "indicates that the response can be stored in a shared cache."
  • private: only the visitor's own browser may keep it. RFC 9111: "a shared cache MUST NOT store the response."

Here's why that matters. The shop's page greets the visitor by name. It reads the name from a cookie, a small note the browser sends to the shop with every request. Ana visited, then Ben, through the same CDN. First, the page sent public:

Ben gets Ana's pagePop out in the code editor (opens in a new tab)JavaScript
// The page greets whoever's cookie it gets, and sends public, max-age=60.// Ana visits, then Ben, through the same CDN. What Ben saw, the same in all 5 runs.const ben = { greeting: "Hello, Ana", cdn: "HIT" }; console.log(`Ben saw "${ben.greeting}". The CDN's log said ${ben.cdn}.`);
  • Line 1 gives the rule. Line 2 says who visited, and in which order.
  • Line 3 holds what Ben saw, and what the CDN wrote in its log.
  • Line 5 prints Ben saw "Hello, Ana". The CDN's log said HIT.

Ben was greeted as Ana. HIT means the CDN answered from its own copy. The shop's log shows no request for Ben's page at all. How could that happen?

A cache files each copy under a cache key, and looks it up by that key. RFC 9111 says many caches "only use the URI as the cache key." nginx's key, by default, is built from the address too: the scheme, the host, and the path with its query. Ana's cookie isn't part of it. To the CDN, Ana's page and Ben's page were the same page.

Real-life analogyA cup with someone else's name

At a busy café, drinks wait on the counter, and the barista calls out the drink: "Mint tea!" Ana's mint tea has "For Ana" written on the cup. Ben ordered a mint tea too. He hears the call, picks up the cup, and reads someone else's name.

In real life: The counter, where anyone can pick up a drink
In HTTP caching: The CDN, a shared cache
In real life: The drink's name the barista calls out
In HTTP caching: The page's address, the CDN's cache key
In real life: "For Ana" written on the cup
In HTTP caching: The greeting the page built from Ana's cookie
In real life: Handing a personal drink straight to its owner
In HTTP caching: private: only the visitor's own browser keeps it

Then the page sent private instead:

Ben gets his own pagePop out in the code editor (opens in a new tab)JavaScript
// The same page, but now it sends private, max-age=60.// Ana visits, then Ben, through the same CDN. What Ben saw, the same in all 5 runs.const ben = { greeting: "Hello, Ben", cdn: "MISS" }; console.log(`Ben saw "${ben.greeting}". The CDN's log said ${ben.cdn}.`);
  • Line 1 gives the new rule. Line 3 holds what Ben saw this time.
  • Line 5 prints Ben saw "Hello, Ben". The CDN's log said MISS.

MISS means the CDN had no copy to give, so it asked the shop. The shop built Ben's own page. Here are the two rules side by side:

Ana visited, then Ben, through the same CDN. The page greets whoever's cookie it gets. public, max-age=60: Ana saw "Hello, Ana", then Ben saw "Hello, Ana". private, max-age=60: Ana saw "Hello, Ana", then Ben saw "Hello, Ben".Ana seesThen Ben seespublic, max-age=60Hello, AnaHello, Anaprivate, max-age=60Hello, AnaHello, Ben
The same page with two rules, one row each. A shaded card means Ben was given Ana's page.

public did make Ben's page faster. It painted at 440 ms with public, and at 656 ms with private, because the CDN didn't have to ask the shop. But it showed the wrong person's name.

private doesn't mean "don't keep it", either. Ana's own browser still kept her page. Exercise 2 shows what happened when she came back.

nginx does skip a response that sets a cookie: "If the header includes the 'Set-Cookie' field, such a response will not be cached." This page only read one.

MDN warns that without private, a personal response "can be stored in a shared cache and end up being reused for multiple users." That, it says, "can cause personal information to leak." So mark anything that changes from one person to the next as private, even if it only reads a cookie.

Invalidation and deploys

Invalidation means making caches stop using a copy that's now wrong. With the rules so far, it happens by itself:

  • The page is checked on every visit, so a deploy shows at once.
  • A changed file gets a new name, so nobody asks for its old copy.

A CDN in the middle changes that. A site can let the CDN keep its pages for a while, to spare the origin work, while browsers still check every time. That's what s-maxage is for: the office fridge's own rule, from HTTP caching in depth. MDN: it "indicates how long the response remains fresh in a shared cache," and it "is ignored by private caches."

The shop sent max-age=0, s-maxage=60 with its page. Browsers treat the page as stale at once, so they check on every visit. The CDN may keep it for 60 seconds. Ana visited, so the CDN stored the page. Then the shop deployed, and Ben visited:

A deploy while the CDN keeps the pagePop out in the code editor (opens in a new tab)JavaScript
// The page sends max-age=0, s-maxage=60: browsers check it every visit, the CDN keeps it 60 s.// Ana visits. The shop deploys a new design and price. Then Ben visits. The same in all 5 runs.const ben = { design: "old", price: "5.25" }; console.log(`Ben saw the ${ben.design} design with the price £${ben.price}.`);
  • Line 1 gives the rule. Line 2 says what happened, in order.
  • Line 3 holds what Ben saw.
  • Line 5 prints Ben saw the old design with the price £5.25.

Ben got two versions of the shop on one screen. The page came from the CDN's copy, made before the deploy: the CDN's log says HIT. That old page linked the old stylesheet, so the design was old. The price went on to the shop, so it was new.

Real-life analogyOld menus on the tables

A restaurant raises its prices and updates the till. But last week's menus are still lying on the tables. A customer orders from an old menu, and the till charges the new price. The fix is simple: collect the old menus as soon as the new ones are printed.

In real life: The menus already on the tables
In HTTP caching: The page the CDN kept, fresh for 60 seconds by s-maxage=60
In real life: The till, which knows the new prices
In HTTP caching: The price, checked with the shop on every visit
In real life: Ordering from an old menu, paying the new price
In HTTP caching: The old design and the new price on one screen
In real life: Collecting the old menus as the new ones go out
In HTTP caching: Refreshing the page at the CDN as a step of the deploy

No header can collect the menus. MDN: "There are no cache directives for clearing already-stored responses from caches on intermediate servers." So the course added a last step to the deploy: one request that told nginx to skip its copy, fetch the page from the shop, and store the new one.

nginx calls that setting proxy_cache_bypass. It "Defines conditions under which the response will not be taken from a cache." Here is the same deploy with that last step:

The deploy refreshes the CDNPop out in the code editor (opens in a new tab)JavaScript
// The same visits, but the deploy ends by refreshing the page at the CDN.// What Ben saw, the same in all 5 runs.const ben = { design: "new", price: "5.25" }; console.log(`Ben saw the ${ben.design} design with the price £${ben.price}.`);
  • Line 1 says what changed: the deploy now ends with the refresh.
  • Line 5 prints Ben saw the new design with the price £5.25.

Cloudflare, which runs a CDN, calls this a purge: telling the CDN to drop its copies. It lets you purge one address, every file with a tag, or everything, and it recommends purging by address. Whichever CDN you use, make this a step of every deploy, so nobody has to remember it.

Clearing one visitor's browser

The other end is one visitor's own browser. When Ana logs out, the shop may want her browser to forget what it kept. The Clear-Site-Data header asks for that. MDN: it tells the browser to remove "browsing data of certain types (cookies, storage, cache)" for the site.

With the value "cache", the browser removes its "locally cached data" for the site. It's like handing back a hotel key card: the room is cleared for whoever comes next.

// The stylesheet sends max-age=3600. Logging out sends Clear-Site-Data: "cache".const visits = [  { visit: "Before logging out", transferSize: 0, paintedAt: 544 },  { visit: "After logging out", transferSize: 405, paintedAt: 656 },]; for (const { visit, transferSize, paintedAt } of visits) {  const stylesheet = transferSize === 0 ? "from the cache" : "downloaded again";  console.log(`${visit}: stylesheet ${stylesheet}, painted at ${paintedAt} ms.`);}
  • Line 1 gives the rules: the stylesheet is kept for an hour, and the logout page sends Clear-Site-Data: "cache".
  • Line 3 is a visit before logging out. Line 4 is the next visit after it.
  • Line 8 turns transferSize into words.
  • It prints Before logging out: stylesheet from the cache, painted at 544 ms. and After logging out: stylesheet downloaded again, painted at 656 ms.

After logging out, Ana's browser had to fetch the page, the stylesheet and the price again, as on a first visit. Her page painted at 656 ms, close to her first visit's 636 ms. That's what a clean start costs.

So use it to remove personal data at logout, not as a way to deploy. It only clears the browser that gets it: MDN notes it "will not affect shared or intermediate caches."

Which policy fits?

Each response below needs a rule. Ask three questions, in this order:

  1. Is it personal? Then it's private.
  2. Does its name change whenever its content does? Then keep it for a year.
  3. May it be a little out of date? Then use a short max-age with stale-while-revalidate.

Anything else gets no-cache, with an ETag. Sort each response by the rule it should get:

Which policy fits?
  • The home page's HTML, the same for every visitor
  • A price API where a minute-old answer would be wrong
  • main.e6141c36.css
  • A JavaScript chunk named by a hash of its content
  • An exchange-rate widget that may be a minute behind
  • A list of trending teas, updated every few minutes
  • A page that greets the visitor by name
  • An API response with the visitor's past orders
Try it yourself
0 of 8 correct

Sort each response by the Cache-Control it should get.

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

Common mix-ups

  • "One rule for every file is simpler, and good enough." No. Keeping everything for an hour gave the fastest second visit, and the old price after a deploy.
  • "A file kept for a year can't be updated." No. Give the new version a new name. The page, checked on every visit, links the new name at once.
  • "stale-if-error protects you from any outage." No. Chrome showed the 500. So did nginx, until its own setting told it to use the old copy.
  • "A cookie keeps a page out of the CDN." No. The CDN filed the page by its address, and Ben was greeted as Ana.
  • "private means the page isn't cached." No. It keeps the page out of shared caches. Ana's own browser still kept her page.
  • "Waiting for s-maxage to run out is a deploy plan." No. Until it ran out, Ben got the old design with the new price.

Several of these mix-ups are about one question: which cache may keep the page, and for how long? Read the table across. Each row is one rule the shop's page used.

Who may keep the shop's page
What the page sendsMay Ana's browser keep it?May the CDN keep it?
no-cacheYes, but it asks the shop before every use.Yes, but it asks the shop before every use.
public, max-age=60Yes, for 60 seconds.Yes, for 60 seconds. It gives that copy to anyone who asks for the same address.
private, max-age=60Yes, for 60 seconds.No. Every request goes on to the shop.
max-age=0, s-maxage=60Yes, but it's stale at once, so the browser asks on every visit.Yes, for 60 seconds.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upA no-cache price

A third visit was added: a repeat visit where the price sent no-cache. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// Two visits with a stale-while-revalidate price, and one where the price sent no-cache.
const visits = [
  { visit: "Second visit", price: "4.50", shownAt: 640.1, transferSize: 0 },
  { visit: "Third visit", price: "5.25", shownAt: 524.4, transferSize: 0 },
  { visit: "Repeat visit, no-cache price", price: "4.50", shownAt: 827.3, transferSize: 300 },
];

for (const { visit, price, shownAt, transferSize } of visits) {
  const from = transferSize === 0 ? "from the cache" : "after asking the shop";
  console.log(`${visit}: £${price} at ${shownAt} ms, ${from}.`);
}

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

    Exercise 2 · Warm-upAna comes back

    Ana comes back to the greeting page under each rule. What does the second line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Ana comes back to the greeting page under each rule. What she saw, the same in all 5 runs.
    const ana = {
      "public, max-age=60": { greeting: "Hello, Ana", cdn: "not asked" },
      "private, max-age=60": { greeting: "Hello, Ana", cdn: "not asked" },
    };
    
    for (const [rule, { greeting, cdn }] of Object.entries(ana)) console.log(`${rule}: Ana saw "${greeting}". The CDN was ${cdn}.`);

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

      Exercise 3 · PracticeThe page after a deploy

      In the playground, with everything no-cache, what status did the origin send for the page, /, on the visit after the deploy?

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

        Exercise 4 · PracticeA stale copy

        When the price API stopped answering, when did Ana's repeat visit show the price, in ms?

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

          Exercise 5 · ChallengeRefreshing in the background at the CDN

          nginx can refresh an expired copy in the background while it gives visitors the stale one, like stale-while-revalidate. Which directive turns that on?

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

            Exercise 6 · ChallengeClear everything

            Which Clear-Site-Data value asks the browser to clear every type of data for the site, even types added in future?

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

              Check your understanding

              For each question, ask three things: who may keep the response, for how long, and what happens when it changes or the server fails.

              Caching strategy quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the public example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The page greets whoever's cookie it gets, and sends public, max-age=60.
                // Ana visits, then Ben, through the same CDN. What Ben saw, the same in all 5 runs.
                const ben = { greeting: "Hello, Ana", cdn: "HIT" };
                
                console.log(`Ben saw "${ben.greeting}". The CDN's log said ${ben.cdn}.`);

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the second line of this stale-if-error example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The price sends max-age=1, stale-if-error=60. Then the price API breaks and answers 500.
                // What Ana's next visit showed, the same in all 5 runs.
                const shown = {
                  "Straight to the shop": "unavailable",
                  "Through the CDN": "unavailable",
                };
                
                for (const [route, price] of Object.entries(shown)) console.log(`${route}: the price was ${price}.`);

                Choose an answer to see the explanation.

              3. Question 3 of 7The shop kept every response for an hour. Right after a deploy, what did a returning visitor see?

                Choose an answer to see the explanation.

              4. Question 4 of 7The price used stale-while-revalidate, and it changed just before a visit. What did that visit show?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why must a page that greets the visitor by name send private?

                Choose an answer to see the explanation.

              6. Question 6 of 7A CDN kept the page for 60 seconds with s-maxage. After a deploy, visitors got the old design with the new price. What fixed it?

                Choose an answer to see the explanation.

              7. Question 7 of 7A site keeps all its JavaScript, its own code and its libraries, in one file with a hash in its name. One line of its own code changes. What does a returning visitor download?

                Choose an answer to see the explanation.

              Key takeaways

              • Give each kind of response its own rule. One rule for everything is either slow or out of date.
              • The page: no-cache with an ETag, so a deploy shows at once.
              • Files with a hash in their name: max-age=31536000, immutable. A change gives them a new name.
              • Keep code that changes often apart from code that rarely does, so one change doesn't rename everything.
              • API data: no-cache if it must be right, or a short max-age with stale-while-revalidate if a slightly old answer is fine.
              • Send stale-if-error, then check what your CDN does with it. Chrome didn't use it.
              • Anything personal is private, even if it only reads a cookie.
              • If a CDN keeps your pages, purge them as a step of every deploy. Clear-Site-Data is for one browser, at logout.

              Remember the one-liner.
              Each kind of response gets its own rule, anything personal stays private, and every deploy clears what the CDN keeps.

              Sources

              The facts on this page come from these standards and docs:

              Coming next: The browser's caches. A browser has more than one cache: the memory cache, the HTTP cache, a service worker's cache, the code cache, and the back/forward cache. Which one served your file?

              CompleteFrontend Clear concepts. Working examples.