Experiments / 06
HTTP Caching
Why does a repeat visit sometimes still wait on the network?
The problem
The fastest request is the one that never happens. A file that’s already on the visitor’s device costs no round trip, no download and no waiting. That makes the HTTP cache the biggest single lever for repeat visits.
It’s also the easiest way to ship a bug. Tell a browser a file is good for a year and it will believe you, including the day you deploy a fix. The art is making files cacheable for as long as possible and updatable the moment they change.
1 · Six visits, one deploy
Each row is a return visit. Each column is a file, with its own Cache-Control policy in the header.
Click through the policies on the left and watch the cells change colour. A red outline means the visitor saw
out-of-date content.
- Avg repeat-visit load
- 404 ms
- Data, 5 repeat visits
- 232 KB
- Network requests
- 8
- Stale responses
- 0
- 200 download
- 304 revalidated
- from cache
- stale-while-revalidate
- stale content
| Visit | index.html | styles.css | app.js | hero.jpg | font.woff2 | Load · data |
|---|---|---|---|---|---|---|
| First visitt = start | 200 | 200 | 200 | 200 | 200 | 1.14 s590 KB |
| 30 seconds latert = 30 s | cache | cache | cache | cache | cache | instant0.0 KB |
| 1 hour latert = 60 min | cache | cache | cache | cache | cache | instant0.0 KB |
| 1 day latert = 24 h | 304 | cache | cache | cache | cache | 510 ms0.4 KB |
| ▲ deploy at 2 d: index.html, styles.css and app.js change | ||||||
| 3 days latert = 3 d | 200 | 200 | 200 | cache | cache | 848 ms230 KB |
| 2 weeks latert = 14 d | 304 | 304 | 304 | 304 | cache | 661 ms2 KB |
index.html (no Cache-Control)styles.css (no Cache-Control)app.js (no Cache-Control)hero.jpg (no Cache-Control)font.woff2 (no Cache-Control)
5 requests were revalidations. A 304 transfers only headers, so it’s cheap in bytes, but it still costs a round trip each, and every visit also pays to open a connection.
With no Cache-Control header the browser guesses a freshness lifetime from Last-Modified (typically 10% of the file’s age). Each file gets a different, unpredictable lifetime, which is why you should set it explicitly.
Hover a cell for details. 304 responses carry headers only (~0.4 KB). Connection setup is paid on each visit that touches the network.
Things to try
- “No headers”. Nothing is configured, yet some files are cached. The browser guesses a lifetime from
Last-Modified, differently for each file. - “Always revalidate”. Safe, and every file still costs a round trip. Look at how small the data is, and how long the load time still is.
- “Cache it all for a year”. The fastest possible repeat visits, and three stale files after the deploy that no visitor’s browser will refresh for a year.
- “Good practice”. The deploy ships new filenames, so nothing is stale, and unchanged files stay cached. The HTML still revalidates every visit.
- “Good + stale-while-revalidate”. The HTML now paints from cache instantly and refreshes in the background.
- Turn fingerprinting off on “Good practice” and see why the filename is what makes a long cache safe.
How the cache decides
For each request the browser asks three questions in order:
- Do I have a copy of this URL? If not, it downloads it. The URL is the cache key, which is why changing the filename defeats the cache.
- Is it still fresh? The copy is fresh for the lifetime the server stated (
max-age). If it is, the browser uses it with no request at all. - If it’s stale, is it still valid? The browser asks the server, sending the validator it saved (
If-None-Match: "etag"). If nothing changed the server replies304 Not Modifiedwith no body, and the browser reuses its copy.
- max-age=N
- Fresh for N seconds. No requests until it expires.
- no-cache
- Does not mean “don’t cache”. It means “store it, but check with me before every reuse”. That’s the 304 cells.
- no-store
- Don’t keep a copy at all. For genuinely sensitive responses, not as a way to be safe about caching.
- immutable
- This file will never change at this URL, so don’t even revalidate it. Pair with a fingerprinted filename and a very long
max-age. - stale-while-revalidate=N
- For N seconds after expiry, serve the stale copy instantly and refresh it in the background. The next visit gets the new one.
- public / private / s-maxage
privatekeeps a response out of shared caches such as a CDN.s-maxagesets a lifetime for shared caches only.- ETag / Last-Modified
- Validators: the fingerprint or timestamp the browser sends back to ask “has this changed?”.
Your own refresh button counts too. A normal reload revalidates the page itself, a hard reload skips the cache altogether, and the back button will happily reuse stale copies. You can’t assume your visitors always behave like a first load.
The pattern that works
- Fingerprint static assets (
app.3f9a1c.js) and serve them withmax-age=31536000, immutable. A new build produces new filenames, so there is nothing to invalidate. - Serve the HTML with
no-cache(or a shortmax-agewithstale-while-revalidate). It’s the document that names the current files, so it must be fresh. - On a CDN, the same rules apply to a cache that sits closer to the visitor. Set
s-maxagefor the CDN, and purge on deploy if you cache HTML there.
This was historically useful, but modern tooling changes the trade-off. “Cache-bust with
?v=2” used to be the standard trick. Build tools now fingerprint filenames for you, and some
intermediaries treat query strings differently, so a changed filename is the more reliable choice.
Common misconceptions
“no-cache means the browser won’t cache it.”
It means “revalidate before use”. The file is stored, and each use costs one conditional request.
Use no-store if you really want nothing kept.
“Cache everything for as long as possible.”
Only what you can change the name of. A long cache on a URL you can’t change is a promise you can’t take back. The “cache it all” preset shows it: instant repeat visits, and a deploy nobody receives.
“More requests are always bad.”
A cached request is free. This is one reason many small, separately cacheable files can beat one bundle that changes on every release.
2 · Compression: what it changes and what it doesn’t
Try the 800 KB script, switch the encoding on, and look at the parse cost. Then try the photo preset.
- Transferred
- 200 KB
- Download time
- 478 ms
- Server CPU / request
- 40 ms
- Parse & compile
- 800 ms
- None800 KBAlways
- gzip248 KBUniversal
- Brotli200 KBAll current browsers, HTTPS only
- Zstandard232 KBNewer: check browser support before relying on it
75% less to download (800 KB → 200 KB), saving 983 ms of download time on this network.
Parse and compile are unchanged at 800 ms. The browser decompresses the response and then processes all 800 KB of source. Compression made the download shorter, not the work after it. That’s Experiment 03.
Typical ratios for text on a mid-range Brotli/gzip setup. Real files vary. Parse cost uses the model from Experiment 03.
Compression in practice
- Compress text, not images. HTML, CSS, JavaScript, JSON and SVG shrink by around three to four times. JPEG, PNG, WebP, AVIF, WOFF2 and video are already compressed.
- Brotli is the usual best choice for static files and is supported by every current browser over HTTPS. Use gzip as the universal fallback.
- Pre-compress static files at build time at the top level, since the slow level costs nothing per request. For responses generated on the fly, use a faster level so compression doesn’t become your bottleneck.
- Zstandard is a newer option that compresses and decompresses quickly. Browser support is newer and not universal, so check before relying on it.
- Minify too. Compression removes redundancy from the transfer. Minification removes bytes the browser would otherwise parse.
“I compress my JavaScript, so it’s cheap.”
Compression made the download cheap. The browser decompresses and then parses the whole thing. The “Parse & compile” number didn’t move.
See it on a real page
-
In DevTools open Network. The Size column shows (memory cache) or
(disk cache) when no request was made, and a status of
304for revalidations. Tick Disable cache to see the difference. - Click a request and read the Response Headers:
Cache-Control,ETag,Content-Encoding. -
From a terminal:
curl -sI -H 'Accept-Encoding: br' https://example.com/app.js | grep -iE 'cache-control|etag|content-encoding|age' - Lighthouse’s “Serve static assets with an efficient cache policy” and “Enable text compression” audits flag the same problems.
Model assumptions
What these simulations simplify
- Five files (20 KB HTML, 60 KB CSS, 150 KB JS, 300 KB image, 60 KB font), one cache entry per URL, always-correct validators, no
Vary, no eviction. - Visits happen at 0, 30 s, 1 hour, 1 day, 3 days and 14 days, with one deploy at 2 days that changes the HTML, CSS and JS.
- With no
Cache-Controlthe freshness lifetime is 10% of the file’s age at fetch (assumed: HTML 2 days, CSS and JS 20, image 120, font 365). - Each visit that touches the network opens a new connection (3 round trips). A 304 is headers only (about 0.4 KB). Background refreshes aren’t counted in load time.
- Compression ratios are typical for text. Server CPU per KB: gzip 0.012 ms, Brotli 0.05 ms (0.9 ms at the top level), Zstandard 0.006 ms. Parse cost is the model from Experiment 03.