The Last CachebenderBook One · Air
Book One · Air
Book One · Air · Lesson 3 of 14
Lesson Three

The Browser Realm

Technique unlocked Windkeeping holding a copy closest to the user

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.

Reference — expand as needed
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:

  1. New code produces new filenames
  2. The new HTML references those names
  3. The browser revalidates the HTML — it is no-cache — and receives the new references
  4. Those filenames are unknown to it, so it fetches them
  5. 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-age that nobody ever read.
The lesson is not “write these headers everywhere.” It is: know what they mean, so you can read the ones already set and change the few that are wrong.

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.
Common misconception

“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.