Book One · Air
The Browser Realm
Your native element. You have bent it already, without knowing the name for it.
Every time you told someone “try a hard refresh,” every time you saw a change you had just made refuse to appear on screen — that was the browser cache, working exactly as designed, and you fighting it blind.
This book gives you the controls.
The Browser Realm
The realm
The browser cache is a store of files kept on the user's own computer by their web browser.
It is the closest cache to the user — there is nothing closer. A hit here involves no network at all. The file is already on their disk. Response time is effectively zero.
It also has a property no other realm has: the storage is free to you. It is the user's disk, not your server's memory. You are not paying for it.
And it has a matching weakness, which will matter enormously in Lesson 11: you cannot reach it. Once you have sent a file to a user's browser with permission to keep it for a year, you have no way to take it back. You cannot delete it. You cannot update it. You can only wait for it to expire.
Everything about browser caching follows from those two facts: it is free and instant, and it is irreversible.
What happens when a page is requested
A page is not one thing the server hands over. It is one file that refers to other files.
The first request. The browser turns the domain into an address, opens a connection, and asks for exactly one thing:
GET /product/42 → server returns HTML
That HTML contains no images and no styling. It contains references to them:
<link rel="stylesheet" href="/app.a91f3c.css"> <script src="/app.7b2e10.js"></script> <img src="/public/atlas.png">
The browser then asks for each one separately. Those files can reference further files, so requests arrive in waves:
Time →
[1] GET /product/42 HTML
│
├─[2] GET /app.a91f3c.css stylesheet ┐
├─[3] GET /app.7b2e10.js script │ in parallel
├─[4] GET /logo.svg image │
└─[5] GET /public/atlas.png image ┘
│
├─[6] GET /inter.woff2 font ← the CSS asked for this
│
├─[7] GET /api/price/42 JSON ┐
├─[8] GET /api/stock/42 JSON │ the JavaScript
└─[9] GET /api/cart JSON ┘ asked for these
A real product page is 50 to 100 requests, not nine.
Two kinds of response
This is the part that decides where caching rules live.
| Who produces it | Where its caching rule is set | |
|---|---|---|
Dynamic — /product/42,
/api/price/42
|
Your application code runs | In your code |
Static — /app.js,
/logo.svg
|
A file is read from disk and sent | In configuration |
Most of those 50–100 requests are the second kind. The request for
/app.js never reaches your application code — it is
answered by nginx, or your platform, or a CDN. You configure the
rule once, by file type or folder, and it applies from then on.
Each response has its own caching rules.
A page uses several responses: HTML, images, styles, scripts, and API data. Each can have its own caching rules. Here, we fetch changing product fields separately so they can have different lifetimes.
How caching instructions travel
An HTTP header is a line of information sent alongside a response, separate from the content itself.
HTTP/1.1 200 OK
Content-Type: application/javascript
Cache-Control: public, max-age=86400
(function(){ ...the JavaScript... })
How you control it
You control the browser cache by sending instructions along with
your responses. The main one is Cache-Control:
Cache-Control: public, max-age=31536000, immutable
That is three separate instructions in one line. Below is each of them, along with the other headers that matter — and where you will actually meet each one.
max-age=Noptional read
Keep this file and reuse it for N seconds without asking me again.
Cache-Control: max-age=300
For the next 300 seconds, the browser uses its stored copy directly. It will not contact your server at all. Your server does not know those requests happened.
In the wild Universal. Every asset on every major site. Nothing has replaced it.
no-cache and no-store — the classic
confusionoptional read
These sound like they mean the same thing. They do not, and mixing them up is one of the most common caching mistakes in the field.
| Instruction | What it actually means |
|---|---|
no-store |
Do not keep a copy at all. Never write it to disk. |
no-cache |
Keep a copy, but ask me before using it each time. |
no-cache does not mean “do not
cache.” It means “cache, but always check
first.” It is a caching instruction, not a refusal to
cache.
Use no-store for anything genuinely sensitive — a
bank statement, a page containing someone's personal details.
Use no-cache when you want the speed benefit of a
stored copy but need to guarantee it is current.
In the wild Both current.
no-store is standard on banking, health and account
pages. no-cache is the standard setting for HTML in
the fingerprinting pattern further down this lesson.
public and privateoptional read
private means only the user's own
browser may store this. Shared caches — the CDN in Book Two, any
company proxy in between — must not keep a copy.
public means anyone along the path
may store it.
This distinction is a safety mechanism. Anything personalised
must be private. The storefront's cart badge is
personalised, and if it were ever marked public, a
shared cache could store one user's cart and hand it to the next
person who came along. That exact failure has a name —
cache poisoning — and it is one of the five
attacks in Lesson 12.
In practice Use private to keep a
personal response out of shared HTTP caches.
immutableoptional read
This file will never change. Do not even ask.
Without it, browsers often revalidate stored files when the user
hits reload. immutable tells the browser not to
bother. It is only safe when the statement is literally true,
which brings us to fingerprinted URLs, below.
In the wild Standard on fingerprinted bundles. Firefox and Safari honour it. Chrome never implemented it, having argued that its own reload handling already avoids the problem — so it is harmless where it is ignored.
Expires — the one you should not writelegacy
The predecessor to max-age. Instead of a duration,
it carries an absolute date:
Expires: Wed, 21 Oct 2026 07:28:00 GMT
It has an obvious flaw: it depends on the browser's clock
agreeing with your server's. max-age counts seconds
from when the response arrived, so no clock comparison is
needed.
In the wild
Legacy. Superseded by max-age,
which wins whenever both are present. Some servers still send it
alongside max-age for very old clients. You will
meet it in real codebases and configuration files. Do not write
new ones.
ETag — asking “has this changed?”optional read
max-age covers the period where the browser can
skip the server entirely. When that time runs out, the browser
would otherwise re-download the whole file — even if it is
byte-for-byte identical to the copy it already holds.
An ETag (entity tag) avoids that waste. It is a short identifier your server attaches to a response, acting as a fingerprint of the content. If the content changes, the ETag changes.
Server → Browser: ETag: "a91f3c"
The browser stores the file along with that tag. Later, when it wants to check:
Browser → Server: If-None-Match: "a91f3c" ("I have version a91f3c")
The server compares. If the content still has that fingerprint:
Server → Browser: 304 Not Modified (no body at all)
304 Not Modified is a response
that says “your copy is still good” and contains no
content. It is a few hundred bytes instead of a few hundred
kilobytes.
If the content has changed, the server sends
200 OK with the new content and a new ETag, as
normal.
In the wild Current and everywhere, especially on APIs. One caveat worth knowing: when several servers sit behind a load balancer, ETags generated from file metadata can differ between them, so the same file produces a different tag depending on which server answered — and the browser re-downloads for no reason. ETags derived from the content itself avoid this.
Last-Modifiedoptional read
The same idea as an ETag, using a date instead of a fingerprint.
The server sends Last-Modified: <date>; the
browser later asks with
If-Modified-Since: <date>; the server replies
304 or sends the new version.
In the wild Still sent by most static file servers, usually beside an ETag. It is the weaker of the two — one-second resolution means a file changed twice within the same second looks unchanged. Treat it as a fallback, not a first choice.
stale-while-revalidateoptional read
Cache-Control: max-age=60, stale-while-revalidate=3600
This says: use the stored copy freely for 60 seconds. After that, for up to an hour, you may still use the old copy immediately — but fetch a fresh one in the background at the same time.
During that extra hour, the browser can show the old copy while checking for an update. Once the check finishes, later requests use the refreshed copy. A request may still wait if there is no usable cached copy.
This example allows a response to be served for up to 61 minutes without a completed freshness check: 60 seconds of freshness, plus an hour of stale reuse. Choose both windows to match how old the content may be.
Here, a request triggers a background check after the copy becomes stale. Lesson 8 introduces refresh-ahead, which refreshes a copy before it expires.
In the wild Current, and growing. It is the mechanism behind Next.js incremental regeneration and the edge caching offered by Vercel and Cloudflare.
Fingerprinted URLs: the trick that makes it all safe
The problem
You ship app.js with
Cache-Control: public, max-age=31536000 — one year.
That is the right setting: the file is used by every page on the
site, and re-downloading it on every visit is pure waste.
Then you find a bug in it. You fix it, you deploy, you refresh the page.
Nothing changes.
The browser has a copy it was told to keep for a year, so it does
not ask whether there is a newer one. That is precisely what
max-age means. Every user who visited before your fix
is holding the broken version, and you have no way to reach them.
The same happens with an image. You upload a corrected product
photo over /public/atlas.png. Everyone who already
loaded the page keeps seeing the old one.
Never change a file, change its name
Instead of app.js, publish
app.a91f3c.js, where a91f3c is derived
from the file's contents. Change one character of the code and
that value changes, so the filename changes.
app.a91f3c.js ← still cached, now referenced by nothing app.5d20e8.js ← never seen before, must be fetched
The browser is not being persuaded to re-check anything. It is being asked for a file it has never heard of, and it has no choice but to download it.
How does the browser learn the new name?
This is the part that makes the whole scheme work, and it is easy to miss.
The HTML is checked before every reuse.
GET /product/42 Cache-Control: no-cache ← always revalidated
<script src="/app.5d20e8.js"></script> ← the new name lives here
The HTML lists the current asset filenames. The browser may keep a copy, but it checks with the server before using it again. Each versioned asset keeps the same contents and can be cached for a long time.
So a deploy spreads like this:
- New code produces new filenames
- The new HTML references those names
-
The browser revalidates the HTML — it is
no-cache— and receives the new references - Those filenames are unknown to it, so it fetches them
- The old files sit unreferenced in the cache until they expire
The user gets the fix on their next page load. No hard refresh, no purge, and you never had to reach into their browser.
The standard pairing
| What | Header |
|---|---|
| Fingerprinted assets | public, max-age=31536000, immutable |
| HTML | no-cache |
That combination is the industry standard on the web. Nearly every major site you use is doing exactly this right now.
Most of this is already handled
You have just read a lesson full of headers, and it would be reasonable to conclude that you now have to write all of them by hand. You do not.
| Who sets it | What they set |
|---|---|
| Your build tool | Fingerprinted filenames |
| Your host or web server — nginx, Apache, Vercel, Netlify, Cloudflare Pages, CloudFront |
max-age and immutable on static
assets, usually by file type or folder
|
| Your framework |
no-cache on rendered HTML,
private on authenticated responses
|
| Your static file server |
ETag and Last-Modified, generated
for you
|
On a typical modern project, the entire static-asset story is handled before you write a line of configuration.
What is left to you — and this is where your attention belongs:
- Your dynamic responses. The HTML your application renders and the API endpoints it exposes. No tool can guess how stale your price is allowed to be. That is the decision from Lesson 1, and it is yours.
- Anything users upload. Nothing fingerprints it for you, as the table above explains.
-
Knowing what the defaults already are. A default
you never looked at is still a decision you made. The common
failure is not a wrong header — it is a
max-agethat nobody ever read.
At the Storefront
| Part | Header | Why |
|---|---|---|
| Asset bundle | public, max-age=31536000, immutable |
Fingerprinted name — a change makes a new URL |
| Product images | public, max-age=86400 |
Change rarely; a day stale is harmless |
| Product details | public, max-age=300 |
Slow-changing, but edits should appear reasonably soon |
| Price |
public, max-age=10, must-revalidate
|
Reuse it for up to 10 seconds, then check for an update |
| Stock count | no-cache |
Store it, but always check — this must be current |
| Reviews | public, max-age=600 |
Read heavily, changes slowly |
| Cart badge | private, no-store |
Personal. Must never be stored anywhere shared. |
“no-cache means don't cache it.”
It means store it, but revalidate before every use. The
one that means “do not store it” is
no-store. People reach for
no-cache intending no-store constantly,
and end up writing sensitive data to disk.