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.
- 01Predict 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. - 02Read the cache in your own dataTell the three apart from Resource Timing's
transferSize: 0, 300, or the file's size plus 300. - 03Cache for a long time, safelyGive hashed files a year, check the page every time, and use
privateandVarywith 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:
// 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.andComing 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.
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.
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:
HTTP/1.1 200 OK
Cache-Control: max-age=3600
ETag: "d65d5cd05880"
Content-Type: image/jpeg- Line 1 is the status line.
200 OKmeans "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.
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-cacheand 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.
- main.css: a 304300 B, 103.2 ms
- hero.jpg: a 304300 B, 103.4 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).
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:
// 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.
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-cachemeans "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-cachedoes not mean "don't cache".no-storemeans "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:
// 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 304andno-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.
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 anAuthorizationheader.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:
// 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: / 304andWith 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.
// 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?
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
ETagthe shop sent with the file - In real life: Reading the number out on the phone
- In HTTP: The browser's
If-None-Matchheader - 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:
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:
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-Matchequals 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:
// 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 304andno 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:
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:
// 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, 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: / 304andFiles 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:
// 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."
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 withmax-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, 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.
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 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 recordedtransferSize, withoutVaryand withVary: Accept. - Line 4 is the rule from before: 0 is the cache, and 300 is a 304.
- Line 6 prints
No Vary: from the cacheandVary: 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:
// 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
transferSizewhen Asha came back, withoutVaryand withVary: *. - Line 5 prints
No Vary: from the cacheandVary: *: 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:
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:
- 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
Sort each file by the Cache-Control it should get.
Common mix-ups
no-cache and no-store are easy to confuse. Read each row as one directive:
| Directive | Is the file kept? | Is it used without asking? | When Asha came back |
|---|---|---|---|
max-age=3600 | Yes. It stays fresh for an hour. | Yes, while it's fresh. | No request for the files. The page painted at 336 ms. |
no-cache | Yes. | No. The browser checks with the server every time. | A 304 for each file. The page painted at 432 ms. |
no-store | No, 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 withmax-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.
privateonly 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.
A third way was added: no-cache, with no validator. What does the last line print?
// 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(", ")}`);no-cache, no validator: / 304, /main.css 200, /hero.jpg 200. With nothing to check against, the browser asked for both files again, and the shop sent them whole. That's as slow as no-store.
Asha came back after a deploy with hashed names. What does it print?
// 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)}`);hashed names: CSS downloaded, hero from the cache. The new stylesheet, 186 bytes plus 300, was new to the cache. The hero kept its name, and its copy was still fresh.
With max-age=3600, when did the page first paint on a reload?
128 ms. The connection was still open, so the one check, for the page, took a single round trip. The stylesheet and the hero came from the cache.
With Vary: *, how long did the hero take on the repeat visit?
103.5 ms, about one round trip. The hero was fresh, but with Vary: * its copy could never match, so the browser checked 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?
Age. RFC 9111 calls it the sender's estimate of the time since the response was made or validated. In MDN's example, max-age=604800 and Age: 86400 leave 518400 seconds of freshness.
MDN's caching guide names one response header that can clear a site's files from the browser's cache. Which header?
Clear-Site-Data, with the value "cache". It clears everything the browser cached for that origin, not one file. MDN adds that it has no effect on shared caches. To change one file, a new name is still the way.
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?
Question 1 of 7What does the first line of this example print?
Read the code, then predict// 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.
Question 2 of 7In this deploy example, what does the first line print?
Read the code, then predict// 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.
Question 3 of 7What does Cache-Control: no-cache mean?
Choose an answer to see the explanation.
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.
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.
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.
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-agesets how long a copy stays fresh.no-cachekeeps it, but checks every time.no-storenever keeps it.- A validator, an ETag or Last-Modified, lets the server answer 304. Without one, no-cache downloads again.
- In the page,
transferSizetells 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.
privatekeeps 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:
- HTTP caching, MDN.
- RFC 9111: HTTP Caching, the IETF.
- Cache-Control header, MDN.
- RFC 9110: HTTP Semantics, the IETF.
- Resource Timing, W3C.
- Prevent unnecessary network requests with the HTTP Cache, web.dev.
- http_response_headers.cc, GetFreshnessLifetimes, Chromium.
- Vary header, MDN.
- Fetch, WHATWG.
- Clear-Site-Data header, MDN.
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.