completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

HTTP caching in depth

Cache-Control, freshness, and revalidation: how browsers decide to reuse a response. A shop's stylesheet and hero served thirteen ways and loaded again in Chrome over a real round trip: max-age, no-cache, no-store, immutable, ETag, Last-Modified, heuristics, a deploy with and without hashed names, and Vary.

By the end, you can
  • 01
    Predict what happens on a repeat visitFrom a file's Cache-Control, say whether it's used at once, checked with a 304, or downloaded again.
  • 02
    Read the cache in your own dataTell the three apart from Resource Timing's transferSize: 0, 300, or the file's size plus 300.
  • 03
    Cache for a long time, safelyGive hashed files a year, check the page every time, and use private and Vary with care.

How a browser decides to reuse a response

Asha visits the Mint tea shop for the first time. Her browser downloads three files: the page itself, its stylesheet, and the hero, a big photo of mint tea at the top. A little later, she comes back to the same page.

Her browser still has all three files from her first visit. Can it use them, or must it fetch them again? And who decides? HTTP caching in the system design course explains the headers. This lesson times what each one costs Asha.

The course timed both of Asha's visits. Here is when the page first appeared each time:

The first visit, and coming backPop out in the code editor (opens in a new tab)JavaScript
// When the page first painted, in ms: the middle value of 5 runs, called the median.const firstVisit = 616;const cameBack = 336; console.log(`First visit: painted at ${firstVisit} ms.`);console.log(`Coming back: painted at ${cameBack} ms.`);console.log(`Coming back saved ${firstVisit - cameBack} ms.`);
  • Line 1 is a comment. The course timed each visit 5 times and kept the middle value, the median. Times are in milliseconds (ms), thousandths of a second.
  • Lines 2 and 3 hold the recorded first contentful paint, or FCP: the moment the first text or image appeared on the screen.
  • Lines 5 and 6 print First visit: painted at 616 ms. and Coming back: painted at 336 ms.
  • Line 7 subtracts one from the other. It prints Coming back saved 280 ms.

Here are her two visits side by side: how each file came, and what her screen showed.

Asha's two visits to the shop, side by side. First visit: the page downloaded, the stylesheet downloaded, the hero downloaded. Her screen at 336 ms: a blank screen; at 616 ms: the heading and the hero. First painted at 616 ms. Coming back: the page checked, and not changed, the stylesheet taken from the cache, the hero taken from the cache. Her screen at 336 ms: the heading and the hero; at 616 ms: the heading and the hero. First painted at 336 ms.the pagethe stylesheetthe heroher screenFirst visitdownloadeddownloadeddownloadedat 336 msblankat 616 msThe shop's page: the heading Mint tea shop and the photo of mint teapainted at 616 msComing backchecked: not changedfrom the cachefrom the cacheat 336 msThe shop's page: the heading Mint tea shop and the photo of mint teaat 616 msThe shop's page: the heading Mint tea shop and the photo of mint teapainted at 336 ms
Asha's two visits. On the first, all three files were downloaded. Coming back, only the page was checked. Shaded: taken from the cache, with no request. The screens are real frames Chrome captured. At 336 ms, her first visit was still blank.

Coming back cut the wait by almost half. Asha's browser didn't even ask the shop for the stylesheet or the photo. It took them from its HTTP cache, the store of responses it keeps from earlier visits. A response is what a server sends back for a request: a status, a few lines about it, and usually a file. The navigation timeline showed that the browser looks there first.

But a browser can't trust a stored file forever. The shop might change its stylesheet tomorrow. So each stored response comes with a date, like food in a fridge.

Real-life analogyMilk with a best-before date

You buy a bottle of milk. The carton says "best before Friday". Until Friday, you just pour it into your tea. You don't phone the shop to ask if it's still good. After Friday, you're not so sure, so you check before you drink it.

In real life: Your fridge
In HTTP: The browser's HTTP cache
In real life: The milk you bought
In HTTP: A stored response, like the hero
In real life: "Best before Friday" on the carton
In HTTP: Cache-Control: max-age=3600: fresh for an hour
In real life: Pouring it without asking anyone
In HTTP: Using the stored copy, with no request
In real life: Friday has passed
In HTTP: The stored copy is stale

A stored response works the same way. While it's fresh, the browser uses it at once, with no request. Once it's stale, past its date, the browser checks with the server first.

Who sets the date? The shop does. With every file, a server sends headers: lines of extra information about the response. One of them, Cache-Control, says how long the file stays fresh. Here are three headers the shop sent with the hero, with the others left out:

The hero's headers, as the shop sent themHTTP
HTTP/1.1 200 OK
Cache-Control: max-age=3600
ETag: "d65d5cd05880"
Content-Type: image/jpeg
  • Line 1 is the status line. 200 OK means "here is the file". It's written in HTTP/1.1's text form, as in Redirects. The shop spoke HTTP/2, which carries the same headers in a different format.
  • Line 2 is Cache-Control: max-age=3600. It says the response stays fresh for 3,600 seconds, one hour. That's the best-before date.
  • Line 3 is an ETag, a label for this exact version of the photo. Think of it as the batch number printed on the carton. The section on 304 responses uses it.
  • Line 4 says what kind of file it is: a JPEG photo.

Asha came back within the hour, so both files were fresh. That's the fastest of three ways a stored file can go. Her two visits, in the picture above, show all three:

  • A fresh copy is used at once. No request goes out. That was the stylesheet and the hero, coming back.
  • A copy that needs a check: the browser asks the shop "has this changed?". If not, the shop answers 304 Not Modified: "keep the one you have". That costs one round trip, a message to the server and its answer back. That was the page, coming back.
  • No usable copy: the browser downloads the whole file. That costs the round trip and all the bytes. That was every file on her first visit.
The main idea

A fresh copy costs nothing. A check costs a round trip. A download costs the round trip and the bytes. The server chooses which, file by file, with its headers. Cache a file for a long time only if its name changes whenever the file does. Check the rest.

How the course timed it

The course built the shop on one computer and timed it in Chrome:

  • The shop lives at shop.test. Its page has a heading, a 185-byte stylesheet at /main.css, and the hero at /hero.jpg, a 103 KB photo.
  • The page itself always comes with Cache-Control: no-cache and an ETag. Only the stylesheet and the hero change from one way to the next.
  • The shop speaks HTTP/2, over HTTPS.
  • A delay proxy, a small go-between program, holds every message for 50 ms each way. So each round trip takes 100 ms. A new connection waits one more round trip, as a TCP handshake would. Data reaches the browser at 5 Mbps, megabits per second.
  • Chrome ran each way 5 times. Each run started with an empty cache and no open connection. Chrome made a first visit, closed its connections, and came back: a repeat visit.
  • For some ways, Chrome reloaded the page instead, or asked for the hero again with fetch().

Here are all thirteen ways. Pick one, then a step. The title gives the first paint. Each row is one file: where it came from, its bytes, and its time.

Playground: come back to the shop
FileBytes, time
  1. main.css: a 304300 B, 103.2 ms
  2. hero.jpg: a 304300 B, 103.4 ms
Try it yourself
no-cache, ETag, repeat visit: FCP 432 ms

The shop was asked for / 304, /main.css 304, /hero.jpg 304. Each row is one file: where it came from, the bytes that crossed the network (the same in all 5 runs), and how long it took (the median of the 5).

Chrome 156 on Apple M1 Pro, over HTTP/2, through a proxy that adds a 100 ms round trip and lets data reach the browser at 5 Mbps.

Start with max-age=3600 and Repeat visit: both files came from the cache. Then pick no-store.

max-age, no-cache, no-store, and immutable

The note on each file is its Cache-Control header. Each instruction inside it is called a directive. One header can hold several, separated by commas: max-age=31536000, immutable is two directives. This section takes four of them, one at a time.

max-age: fresh for a while

max-age=N sets the best-before date. RFC 9111, the standard for HTTP caching, says the response is "to be considered stale after its age is greater than the specified number of seconds." A response's age is the time since the server made it. So max-age=3600 keeps it fresh for an hour.

On her first visit, the shop got three requests: the page, the stylesheet and the hero. Here is everything it got when Asha came back:

Coming back with max-agePop out in the code editor (opens in a new tab)JavaScript
// Asha came back. The stylesheet and the hero had Cache-Control: max-age=3600.// Everything the shop was asked for, the same in all 5 runs:const shopGot = ["/ 304"]; console.log(`The shop got ${shopGot.length} request: ${shopGot.join(", ")}`);
  • Lines 1 and 2 are comments. The shop kept a log of every request it got.
  • Line 3 is that log. Each entry is a path and the status the shop answered. / is the page itself.
  • Line 5 prints The shop got 1 request: / 304.

The stylesheet and the hero aren't in the log. The browser never asked for them, because they were fresh. The one request was for the page. The shop sends its page with no-cache, so the browser checked it, and the shop answered 304: "not changed".

So why did Asha still wait 336 ms? Because of that one check. Chrome had closed its connections, so it opened a new one: a round trip for the TCP handshake, then one for TLS. The check took a third. That's 300 ms before any of the page arrived. TLS without the wait showed why TLS 1.3 needs only one round trip.

Why it matters: a fresh file costs no request at all. No round trip and no bytes: that's the cheapest a file can be.

no-cache and no-store: check first, or never keep

Some files mustn't be used without a check. Others mustn't be kept at all.

Real-life analogyTwo notes on a carton

One carton says: "Check with the shop before each glass." You keep it in the fridge, but you phone the shop before you pour. Most days they say it's fine. Another carton says: "Do not keep." That one never goes in the fridge, so each time you want milk, you buy a new carton.

In real life: "Check with the shop before each glass"
In HTTP: no-cache: keep it, but check before each use
In real life: Phoning, then pouring from the same carton
In HTTP: A 304: the stored copy is used
In real life: "Do not keep"
In HTTP: no-store: never store it
In real life: Buying a new carton every time
In HTTP: Downloading the whole file on every visit
  • no-cache means "store it, but check with the server before every use". RFC 9111 says the response "MUST NOT be used to satisfy any other request without forwarding it for validation". Validation is that check. MDN warns that, despite its name, no-cache does not mean "don't cache".
  • no-store means "never store it". RFC 9111 says a cache "MUST NOT store any part" of the response.

The course served the stylesheet and the hero both ways. With no-cache, they also had an ETag. Here is what the shop got when Asha came back:

Check first, or never keepPop out in the code editor (opens in a new tab)JavaScript
// Asha came back. Everything the shop was asked for, the same in all 5 runs:const shopGot = {  "no-cache": ["/ 304", "/main.css 304", "/hero.jpg 304"],  "no-store": ["/ 304", "/main.css 200", "/hero.jpg 200"],}; for (const [policy, requests] of Object.entries(shopGot)) console.log(`${policy}: ${requests.join(", ")}`);
  • Lines 3 and 4 hold the shop's two logs, one for each directive.
  • Line 7 prints one line for each: no-cache: / 304, /main.css 304, /hero.jpg 304 and no-store: / 304, /main.css 200, /hero.jpg 200.

With no-cache, the browser had both files and asked about each one. Each answer was a 304, so no file came back. With no-store, it had kept nothing to ask about. So the shop sent both files whole, with 200.

The first paints show the cost. With max-age, the page painted at 336 ms. With no-cache, at 432 ms: the checks added about one round trip, 96 ms. With no-store, at 604 ms: almost as slow as her first visit.

Why it matters: for a file that can change, no-cache is the safe choice, not no-store. MDN advises against using no-store freely: you lose the cache, and even the browser's back/forward cache.

Your fridge, and the office fridge

So far, every stored copy lived in Asha's own browser. But a response can also be stored on its way to her, in a cache that many visitors share.

Real-life analogyYour fridge, and the office fridge

Only you use the fridge at home, so you can keep anything there. At the office, everyone shares one fridge. You wouldn't leave your private lunch there for anyone to take. And the office fridge may have its own rule for how long things can stay.

In real life: Your fridge at home
In HTTP: A private cache, like Asha's browser cache
In real life: The shared fridge at the office
In HTTP: A shared cache, like a CDN, used by many visitors
In real life: Keeping your private lunch out of the office fridge
In HTTP: private: no shared cache may store it
In real life: The office fridge's own rule for how long food stays
In HTTP: s-maxage: how long it stays fresh in shared caches

RFC 9111 names the two kinds of cache:

  • A private cache is "dedicated to a single user". Asha's browser cache is one.
  • A shared cache "stores responses for reuse by more than one user". A CDN, a content delivery network of servers near the visitors, is one.

Three directives speak to them:

  • private: RFC 9111 says "a shared cache MUST NOT store the response". Asha's browser still may. MDN says to add it to anything personal, like pages after a login.
  • public: any cache may store it. MDN says it's only needed for a request that carries a login, in an Authorization header.
  • s-maxage=N: a separate freshness time, for shared caches only. MDN: it "is ignored by private caches".

This lesson's shop had no shared cache in front of it. A caching strategy adds one, and tests private and s-maxage there.

Reloads, and immutable

Now Asha presses Reload. MDN explains that a reload checks stored files with the server, even fresh ones. For a file that never changes, that check is a wasted round trip.

The immutable directive says "this file won't change while it's fresh, so don't check it". But MDN adds a note about Chrome: "instead of implementing that directive, Chrome has changed its implementation so that revalidation is not performed during reloads for subresources." A subresource is a file the page loads, like the stylesheet and the hero.

The course reloaded the page with and without immutable:

A reload, with and without immutablePop out in the code editor (opens in a new tab)JavaScript
// What the shop was asked for when Asha pressed Reload, the same in all 5 runs.const withoutImmutable = ["/ 304"]; // max-age=3600const withImmutable = ["/ 304"]; // max-age=3600, immutable console.log(`Without immutable: ${withoutImmutable.join(", ")}`);console.log(`With immutable: ${withImmutable.join(", ")}`);
  • Lines 2 and 3 hold what the shop got on each reload. The comments give the files' Cache-Control.
  • Lines 5 and 6 print Without immutable: / 304 and With immutable: / 304.

Either way, Chrome checked the page and nothing else. So in Chrome, immutable changes nothing. Firefox and Safari do use it:

A hard reload is different. It skips the cache on purpose. MDN calls it a force reload.

A hard reloadPop out in the code editor (opens in a new tab)JavaScript
// What the shop was asked for on a hard reload, which skips the cache. The same in all 5 runs:const shopGot = ["/ 200", "/main.css 200", "/hero.jpg 200"]; console.log(`The shop got ${shopGot.length} requests: ${shopGot.join(", ")}`);
  • Line 2 is the shop's log: the page and both files, each answered with 200.
  • Line 4 prints The shop got 3 requests: / 200, /main.css 200, /hero.jpg 200.

Why it matters: they test different things. A reload still uses fresh files. A hard reload downloads everything, as a first visit does.

ETag, Last-Modified, and 304 responses

Back to the no-cache run. The shop said the hero had "not changed", without sending it. How can the shop know what Asha has, without seeing her copy?

Real-life analogyPhoning the shop with the batch number

Your milk's date has passed, but it may still be fine. You phone the shop and read out the batch number on the carton. "That's still our latest. Keep it." The call took a minute, but you didn't buy a new bottle.

In real life: The batch number printed on the carton
In HTTP: The ETag the shop sent with the file
In real life: Reading the number out on the phone
In HTTP: The browser's If-None-Match header
In real life: "That's still our latest. Keep it."
In HTTP: 304 Not Modified, with no file
In real life: The minute you spent on the phone
In HTTP: One round trip
In real life: "No, come and get the new batch"
In HTTP: 200, with the new file

The batch number is a validator: a short label for one exact version of a file. The browser stores it with its copy. There are two kinds:

  • An ETag, short for entity tag, is a label the server makes up. MDN calls it "an arbitrary value generated by the server". The course's shop used part of a hash of the file, a short code worked out from its bytes. So any change to the file changes its ETag.
  • Last-Modified is the date and time the file last changed.

To check its copy, the browser sends a conditional request: a normal request with one extra header that means "only send it if it changed". With an ETag, that header is If-None-Match. With only a date, it's If-Modified-Since.

If nothing changed, the server answers 304 Not Modified. RFC 9110, the standard that defines HTTP, says a 304 "cannot contain content". It has a status and headers, but no body, the part that holds the file. Here is the hero's check from the no-cache run, with the other headers left out:

The browser's question, and the shop's answerHTTP
GET /hero.jpg HTTP/1.1
If-None-Match: "d65d5cd05880"

HTTP/1.1 304 Not Modified
Cache-Control: no-cache
ETag: "d65d5cd05880"
  • Line 1 asks for the hero.
  • Line 2 sends back the ETag from Asha's stored copy, in If-None-Match.
  • Line 4 is the shop's answer: 304 Not Modified.
  • Lines 5 and 6 repeat the caching headers, so the browser can update what it stored with its copy. Nothing comes after them: no photo.

On the shop's side, this is the function the course's shop ran for every file:

The shop decides: 304, or the fileJavaScript
function sendFile(request, response, file) {  const headers = { "cache-control": file.cacheControl, etag: file.etag, "last-modified": file.lastModified, vary: file.vary };  for (const name of Object.keys(headers)) if (!headers[name]) delete headers[name];  const etagMatches = file.etag && request.headers["if-none-match"] === file.etag;  const unchangedSince = file.lastModified && !request.headers["if-none-match"] && Date.parse(request.headers["if-modified-since"]) >= Date.parse(file.lastModified);  if (etagMatches || unchangedSince) return response.writeHead(304, headers).end();  response.writeHead(200, { ...headers, "content-type": file.type }).end(file.body);}
  • Lines 2 and 3 gather the caching headers that this run sends.
  • Line 4 is true when the browser's If-None-Match equals the file's ETag.
  • Line 5 compares dates instead, but only when there's no If-None-Match. RFC 9110 says a server "MUST ignore If-Modified-Since if the request contains an If-None-Match header field".
  • Line 6 answers 304 when either check passes: the caching headers, and no body.
  • Line 7 sends the whole file otherwise, with 200.

How can your own page tell which of the three happened? Navigation and Resource Timing showed the clue: each file's transferSize, the bytes that crossed the network for it. The Resource Timing standard sets it:

  • 0 when the file came from the cache, with no request;
  • 300 when the browser checked it and got a 304;
  • otherwise, the size of the file plus 300.

The 300 stands in for the headers, whose real size "might expose the presence of certain cookies". Here are the sizes from three no-cache runs, each with a different validator:

Which validator got a 304?Pop out in the code editor (opens in a new tab)JavaScript
// Resource Timing's transferSize when Asha came back, with no-cache, in bytes. The same in all 5 runs:const transferSize = {  "an ETag": { css: 300, hero: 300 },  "Last-Modified": { css: 300, hero: 300 },  "no validator": { css: 485, hero: 103109 },};const kind = (bytes) => (bytes === 0 ? "from the cache" : bytes === 300 ? "a 304" : "downloaded"); for (const [validator, { css, hero }] of Object.entries(transferSize)) console.log(`${validator}: CSS ${kind(css)}, hero ${kind(hero)}`);
  • Lines 3 to 5 hold the stylesheet's and the hero's recorded transferSize, for each validator.
  • Line 7 turns a size into words, with the standard's rule.
  • Line 9 prints an ETag: CSS a 304, hero a 304, Last-Modified: CSS a 304, hero a 304 and no validator: CSS downloaded, hero downloaded.

Both validators worked. With an ETag, Chrome sent If-None-Match; with only Last-Modified, If-Modified-Since. With no validator, it had nothing to ask with, so both files came again: 485 and 103,109 bytes, each the file plus 300. Here is what each answer cost the hero:

The hero on a repeat visit, median of 5: how long it took, and its transferSize. max-age=3600: 0.5 ms, 0 bytes. no-cache, ETag: 103.4 ms, 300 bytes. no-cache, Last-Modified: 103 ms, 300 bytes. no-cache, no validator: 267.5 ms, 103109 bytes. no-store: 268.5 ms, 103109 bytes.max-age=36000.5 ms, 0 Bno-cache, ETag103.4 ms, 300 Bno-cache, Last-Modified103 ms, 300 Bno-cache, no validator267.5 ms, 103,109 Bno-store268.5 ms, 103,109 B
The hero when Asha came back: how long it took, and the bytes that crossed the network. Shaded: a 304. It saved the bytes, but not the round trip.

From the cache, the hero took 0.5 ms. A 304 took about one round trip: 103.4 ms. The no-store download took 268.5 ms: the round trip, plus 103 KB at 5 Mbps.

Why it matters: a 304 saves the bytes, not the round trip. And without a validator, no-cache costs as much as no-store, so always send one. web.dev's advice: "We recommend using ETag because it's more accurate."

Another clue, deliveryType, says "cache" both for a fresh copy and for a 304. So use transferSize to tell those two apart.

Heuristic caching

What if the shop sends no Cache-Control at all? You might expect the browser to keep nothing. It doesn't. MDN: "HTTP is designed to cache as much as possible, so even if no Cache-Control is given, responses will get stored and reused if certain conditions are met."

That's called heuristic caching. A heuristic is a guess based on what you can see. Here, the browser looks at Last-Modified. It's like a jar with no date on it: if it has sat unopened for ten days, you guess it will keep for one more.

How long, exactly? RFC 9111 suggests a share of the time since the file last changed: "A typical setting of this fraction might be 10%." Chrome's own source code does just that. It takes the time from Last-Modified to when the response was sent, and divides it by 10:

A tenth of the time since the last changePop out in the code editor (opens in a new tab)JavaScript
// No Cache-Control: Chrome keeps a file fresh for a tenth of the time since it last changed.const changedTenDaysAgo = 10 * 24 * 60 * 60; // in secondsconst changedFiveSecondsAgo = 5; console.log(`Changed 10 days ago: fresh for ${changedTenDaysAgo / 10} seconds.`);console.log(`Changed 5 seconds ago: fresh for ${changedFiveSecondsAgo / 10} seconds.`);
  • Line 2 is ten days in seconds: 10 days of 24 hours, of 60 minutes, of 60 seconds.
  • Line 3 is five seconds.
  • Line 5 prints Changed 10 days ago: fresh for 86400 seconds. That's one day.
  • Line 6 prints Changed 5 seconds ago: fresh for 0.5 seconds.

The course tried both. It sent the stylesheet and the hero with no Cache-Control, only a Last-Modified date:

No Cache-Control at allPop out in the code editor (opens in a new tab)JavaScript
// No Cache-Control, only Last-Modified. Asha came back seconds later.// Everything the shop was asked for, the same in all 5 runs:const shopGot = {  "changed 10 days ago": ["/ 304"],  "changed 5 seconds ago": ["/ 304", "/main.css 304", "/hero.jpg 304"],}; for (const [files, requests] of Object.entries(shopGot)) console.log(`Files ${files}: ${requests.join(", ")}`);
  • Lines 4 and 5 hold the shop's two logs.
  • Line 8 prints Files changed 10 days ago: / 304 and Files changed 5 seconds ago: / 304, /main.css 304, /hero.jpg 304.

The files changed 10 days earlier were fresh for a day, so the browser never asked about them: the page painted at 336 ms. The files changed 5 seconds earlier were stale after half a second. So the browser checked them with If-Modified-Since, and the page painted at 436 ms.

Why it matters: a file nobody meant to cache could be reused for a whole day, with no check. If the shop changed it that day, Asha would see the old one. MDN's advice: give every response an explicit Cache-Control header.

Hashed file names for long caching

If fresh files are the fastest, why not keep them fresh for a year? A year is max-age=31536000: 31,536,000 seconds. But watch what happens when the shop changes the file.

The course sent the stylesheet with a year-long max-age. After Asha's first visit, the shop deployed a new main.css, which turns the heading from green to purple. Then Asha came back:

A deploy, at the same namePop out in the code editor (opens in a new tab)JavaScript
// main.css had max-age=31536000: one year. The shop deployed a new main.css, then Asha came back.const newHeading = "rgb(122, 62, 157)"; // the heading's colour in the new main.cssconst herHeading = "rgb(26, 127, 90)"; // the colour on Asha's screen, the same in all 5 runsconst shopGot = ["/ 304"]; console.log(herHeading === newHeading ? "Asha saw the new stylesheet." : "Asha still saw the old stylesheet.");console.log(`The shop was asked for: ${shopGot.join(", ")}`);
  • Line 2 is the heading's colour in the new stylesheet: purple.
  • Line 3 is the colour on Asha's screen: the old green, in all 5 runs. Line 4 is the shop's log.
  • Line 6 compares the two colours. It prints Asha still saw the old stylesheet.
  • Line 7 prints The shop was asked for: / 304.

Asha's copy was fresh for a year, so her browser never asked about it. The page was checked, but it hadn't changed: it still linked main.css. web.dev puts the problem this way: "How do you notify browsers to update the 'stale' cached copy of the file? You can't, at least not without changing the URL of the resource."

Real-life analogyA new name on the carton

The shop changes its mint milk recipe. It can't reach into Asha's fridge and swap her carton. So it sells the new recipe under a new name, "Minty milk". Before each trip, Asha checks the shop's list of what's on sale, and buys what it names. Nobody buys the old recipe again.

In real life: The carton in Asha's fridge, good for a year
In HTTP: main.css, stored with max-age=31536000
In real life: Selling the new recipe under a new name
In HTTP: A new file name for the new version: main.a3df947e.css
In real life: The shop's list of what's on sale, checked before each trip
In HTTP: The page, sent with no-cache, which names the new file
In real life: The old carton, never bought again
In HTTP: The old stylesheet, which the new page no longer names

That's cache busting. MDN calls it "a technique to make a response cacheable over a long period by changing the URL when the content changes." web.dev suggests putting "a fingerprint of the file, or a version number" in its name, like style.x234dff.css. A fingerprint is a hash: change the file, and it changes.

The course ran the same deploy with hashed names. The shop named each version of the stylesheet after the first 8 characters of its hash: main.f53dcf06.css, then main.a3df947e.css.

The same deploy, with hashed namesPop out in the code editor (opens in a new tab)JavaScript
// The same deploy, but each version of the stylesheet is named after a hash of its content.const newHeading = "rgb(122, 62, 157)"; // the heading's colour in the new stylesheetconst herHeading = "rgb(122, 62, 157)"; // the colour on Asha's screen, the same in all 5 runsconst shopGot = ["/ 200", "/main.a3df947e.css 200"]; console.log(herHeading === newHeading ? "Asha saw the new stylesheet." : "Asha still saw the old stylesheet.");console.log(`The shop was asked for: ${shopGot.join(", ")}`);
  • Line 3 is the colour on Asha's screen: the new purple, in all 5 runs.
  • Line 6 prints Asha saw the new stylesheet.
  • Line 7 prints The shop was asked for: / 200, /main.a3df947e.css 200.

This time the page had changed, because it linked a new name. So the page's check got a 200 and the new page. It named main.a3df947e.css, which the cache had never seen, so the browser fetched it. The hero kept its name, and came from the cache.

A deploy changed the stylesheet. Same name: first visit main.css; repeat visit main.css, cached, the old stylesheet. Hashed name: first visit main.f53dcf06.css; repeat visit main.a3df947e.css, downloaded, the new stylesheet.First visitAfter the deploySame namemain.cssmain.css, cachedHashed namemain.f53dcf06.cssmain.a3df947e.css, downloaded
The same deploy, two ways of naming the stylesheet. Shaded: Asha still used the old stylesheet, though the shop had a new one.

The same-name visit painted sooner, at 340 ms against 440, but it painted the old design. Why it matters: web.dev suggests "Cache-Control: max-age=31536000 for versioned resources", and no-cache for files that must be checked before every use. That's the shop's pattern: hashed files kept for a year, and a page that names them, checked every time.

Vary

So far, the browser found a stored copy by its URL alone: one URL, one copy. What a cache looks a copy up by is its cache key. RFC 9111 says that's at least the request's method and its URL. The Vary response header adds more: request headers that also have to match.

Picture a fridge with one shelf for each URL. Vary splits a shelf. In MDN's example, Vary: Accept-Language keeps an English page and a Japanese page at the same URL apart.

The course tried Vary: Accept. The Accept header says which formats the browser will take. The page showed the hero with <img>, then asked for it again with fetch(). The Fetch standard gives fetch() the value */*, anything, and an image a list of image types. So the two sent different Accept headers:

The hero, asked for againPop out in the code editor (opens in a new tab)JavaScript
// The page showed the hero with <img>, then asked for it again with fetch(). Both times max-age=3600.// The fetch's transferSize in bytes, the same in all 5 runs:const fetchBytes = { "No Vary": 0, "Vary: Accept": 300 };const kind = (bytes) => (bytes === 0 ? "from the cache" : bytes === 300 ? "a 304" : "downloaded"); for (const [vary, bytes] of Object.entries(fetchBytes)) console.log(`${vary}: ${kind(bytes)}`);
  • Line 3 holds the fetch()'s recorded transferSize, without Vary and with Vary: Accept.
  • Line 4 is the rule from before: 0 is the cache, and 300 is a 304.
  • Line 6 prints No Vary: from the cache and Vary: Accept: a 304.

The hero was fresh both times. But with Vary: Accept, the stored copy went with the <img>'s Accept header, and the fetch() sent another. So the browser checked with the shop: a round trip for a file it already had.

Vary: * goes further. RFC 9111: "A stored response with a Vary header field value containing a member '*' always fails to match." The course sent the hero with it, and Asha came back:

Vary: * on a repeat visitPop out in the code editor (opens in a new tab)JavaScript
// Asha came back. The hero had max-age=3600. Its transferSize in bytes, the same in all 5 runs:const heroBytes = { "No Vary": 0, "Vary: *": 300 };const kind = (bytes) => (bytes === 0 ? "from the cache" : bytes === 300 ? "a 304" : "downloaded"); for (const [vary, bytes] of Object.entries(heroBytes)) console.log(`${vary}: ${kind(bytes)}`);
  • Line 2 holds the hero's recorded transferSize when Asha came back, without Vary and with Vary: *.
  • Line 5 prints No Vary: from the cache and Vary: *: a 304.

The hero had max-age=3600, so it was fresh. With Vary: *, it could never match, so the browser checked it anyway. The page painted at 436 ms, against 336 without Vary.

Why it matters: every header in Vary splits the cache further. MDN warns against Vary: User-Agent, which has "a very large number of variations". Vary only on headers that really change the response, and never on *.

Which Cache-Control?

Here is Asha's repeat visit nine ways, from the fastest first paint to the slowest. Each bar is the median of 5 runs:

Median FCP of 5 repeat visits, after the connections closed. max-age=3600: 336 ms, no request for the files. No Cache-Control, 10 days old: 336 ms, no request for the files. A year, same name: 340 ms, no request for the files. no-cache, ETag: 432 ms. no-cache, Last-Modified: 436 ms. No Cache-Control, 5 s old: 436 ms. Vary: *: 436 ms. no-cache, no validator: 604 ms. no-store: 604 ms.max-age=3600336 msNo Cache-Control, 10 days old336 msA year, same name340 msno-cache, ETag432 msno-cache, Last-Modified436 msNo Cache-Control, 5 s old436 msVary: *436 msno-cache, no validator604 msno-store604 ms
Asha's repeat visit, nine ways. Shaded: the stylesheet and the hero came from the cache with no request; only the page was checked. The third bar was fast, but it showed the old stylesheet.

The fastest bars sent no request for the stylesheet or the hero. But speed isn't everything. The third bar showed the old stylesheet. The second, with no Cache-Control, was fast only because a heuristic guessed.

So pick each header by what the file is. Sort each file by the header it should get:

Which Cache-Control?
  • main.a3df947e.css, named after a hash of its content
  • /images/teapot.v3.jpg, renamed whenever it changes
  • The page's HTML at /
  • /logo.png, which is replaced now and then at the same URL
  • A page with a customer's bank statement
  • An API response with saved card details
Try it yourself
0 of 6 correct

Sort each file 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

no-cache and no-store are easy to confuse. Read each row as one directive:

Three directives, side by side
DirectiveIs the file kept?Is it used without asking?When Asha came back
max-age=3600Yes. It stays fresh for an hour.Yes, while it's fresh.No request for the files. The page painted at 336 ms.
no-cacheYes.No. The browser checks with the server every time.A 304 for each file. The page painted at 432 ms.
no-storeNo, never.No. There's no copy to use.Both files came again, whole. The page painted at 604 ms.
  • "no-cache means don't keep it." No. The file is kept, then checked before each use. With no-cache, Asha's files came back as 304s, 300 bytes each.
  • "No Cache-Control means no caching." No. Files that last changed 10 days earlier came straight from the cache, with no request.
  • "A 304 is free." No. It saves the bytes, not the round trip. The page painted at 432 ms with no-cache, against 336 with max-age.
  • "A deploy reaches every visitor." No. At the same name, Asha kept the old stylesheet, because her copy was still fresh.
  • "immutable matters in Chrome." No. Chrome's reload skipped the stylesheet and the hero with or without it. It matters in Firefox and Safari.
  • "private means the browser can't keep it." No. private only keeps a response out of shared caches. Asha's own browser may still store it.

Practice exercises

Try each one before you open the hints. Exercises 1 and 2 are code. Exercises 3 and 4 use the playground in the first section. Exercises 5 and 6 send you to MDN.

Exercise 1 · Warm-upNo validator

A third way was added: no-cache, with no validator. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// Asha came back. Everything the shop was asked for, the same in all 5 runs:
const shopGot = {
  "no-cache": ["/ 304", "/main.css 304", "/hero.jpg 304"],
  "no-store": ["/ 304", "/main.css 200", "/hero.jpg 200"],
  "no-cache, no validator": ["/ 304", "/main.css 200", "/hero.jpg 200"],
};

for (const [policy, requests] of Object.entries(shopGot)) console.log(`${policy}: ${requests.join(", ")}`);

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

    Exercise 2 · Warm-upAfter a deploy

    Asha came back after a deploy with hashed names. What does it print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Resource Timing's transferSize when Asha came back after a deploy, with hashed names, in bytes. The same in all 5 runs:
    const transferSize = {
      "hashed names": { css: 486, hero: 0 },
    };
    const kind = (bytes) => (bytes === 0 ? "from the cache" : bytes === 300 ? "a 304" : "downloaded");
    
    for (const [validator, { css, hero }] of Object.entries(transferSize)) console.log(`${validator}: CSS ${kind(css)}, hero ${kind(hero)}`);

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

      Exercise 3 · PracticeA reload

      With max-age=3600, when did the page first paint on a reload?

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

        Exercise 4 · PracticeVary: *

        With Vary: *, how long did the hero take on the repeat visit?

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

          Exercise 5 · ChallengeHow old is it?

          A shared cache that hands Asha a stored response adds a header saying how many seconds it has already kept it. Which response header is that?

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

            Exercise 6 · ChallengeClearing the cache

            MDN's caching guide names one response header that can clear a site's files from the browser's cache. Which header?

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

              Check your understanding

              For each question, ask three things. Is the stored copy fresh? Is there a validator? Does the request match the stored one?

              HTTP caching quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the first line of this example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Asha came back. Everything the shop was asked for, the same in all 5 runs:
                const shopGot = {
                  "no-cache": ["/ 304", "/main.css 304", "/hero.jpg 304"],
                  "no-store": ["/ 304", "/main.css 200", "/hero.jpg 200"],
                };
                
                for (const [policy, requests] of Object.entries(shopGot)) console.log(`${policy}: ${requests.join(", ")}`);

                Choose an answer to see the explanation.

              2. Question 2 of 7In this deploy example, what does the first line print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // main.css had max-age=31536000: one year. The shop deployed a new main.css, then Asha came back.
                const newHeading = "rgb(122, 62, 157)"; // the heading's colour in the new main.css
                const herHeading = "rgb(26, 127, 90)"; // the colour on Asha's screen, the same in all 5 runs
                const shopGot = ["/ 304"];
                
                console.log(herHeading === newHeading ? "Asha saw the new stylesheet." : "Asha still saw the old stylesheet.");
                console.log(`The shop was asked for: ${shopGot.join(", ")}`);

                Choose an answer to see the explanation.

              3. Question 3 of 7What does Cache-Control: no-cache mean?

                Choose an answer to see the explanation.

              4. Question 4 of 7Chrome reloaded a page whose files had max-age=3600 and no immutable. What did the shop get?

                Choose an answer to see the explanation.

              5. Question 5 of 7A file has no Cache-Control, and its Last-Modified is 10 days old. What did Chrome do on a repeat visit seconds later?

                Choose an answer to see the explanation.

              6. Question 6 of 7Why did a hero with Vary: * get a 304 on the repeat visit, even with max-age=3600?

                Choose an answer to see the explanation.

              7. Question 7 of 7A response says Cache-Control: private. Who may store it?

                Choose an answer to see the explanation.

              Key takeaways

              • A fresh copy costs no request. A 304 costs a round trip. A download costs the round trip and the bytes.
              • max-age sets how long a copy stays fresh. no-cache keeps it, but checks every time. no-store never keeps it.
              • A validator, an ETag or Last-Modified, lets the server answer 304. Without one, no-cache downloads again.
              • In the page, transferSize tells you which: 0 from the cache, 300 for a 304, more for a download.
              • Always send Cache-Control. Without it, a heuristic decides.
              • Cache hashed files for a year. Send the page with no-cache and an ETag.
              • private keeps personal responses out of shared caches.
              • Vary only on headers that really change the response, and never on *.

              Remember the one-liner.
              Fresh is free, a check costs a round trip, and a new name is how you change a file that's cached for a year.

              Sources

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

              Coming next: A caching strategy, also in Fast servers, caches, and networks. It decides how long to cache the page, its files and API responses, and how to change them safely, with a real shared cache in front of the shop.

              CompleteFrontend Clear concepts. Working examples.