Ch. 20 · Networking

HTTP Cache-Control and Response Freshness

Who may store a response, how long it stays fresh and when it must be revalidated, shown with a toy CDN that leaks one user's page to another.

~6 min readintermediateupdated Oct 11, 2026

Cache-Control is a small header with a lot of ways to go wrong, partly because its directive names don’t mean what they sound like. no-cache doesn’t stop caching. max-age isn’t only about the browser. private has nothing to do with encryption. Interviewers ask about it because the mistakes are expensive in both directions: cache too little and every request hits your origin; cache the wrong thing and one customer sees another’s account page.

This note splits the header into the questions it actually answers, then runs a toy shared cache to show the classic leak and its fixes.

Three separate questions

Every caching decision breaks down into three parts, and each directive belongs to one of them.

Who may store the response? A private cache belongs to one user, which in practice means the browser. A shared cache serves many users: a CDN, a reverse proxy, a corporate proxy. private says only private caches may store it. public explicitly allows shared caches, even in cases where they’d otherwise hold back. no-store says nobody keeps a copy.

How long is it fresh? max-age=N says the response can be reused without asking the origin for N seconds. s-maxage=N overrides that for shared caches only. A cache tells you how long it has held a copy with the Age header. If a response has no freshness information but does have Last-Modified, caches are allowed to guess a lifetime heuristically, which is a common source of “why is this stale?” puzzles.

What happens when it isn’t fresh? A stale response can be revalidated: the cache asks the origin “is my copy still good?” with If-None-Match or If-Modified-Since, and a 304 Not Modified means yes. no-cache requires that check before every reuse, even if the copy is fresh. must-revalidate says once it’s stale, never serve it without checking. stale-while-revalidate=N lets a cache serve a stale copy for up to N seconds while it refreshes in the background.

Here’s what that looks like on a real response from MDN’s HTTP docs:

curl -sI https://developer.mozilla.org/en-US/docs/Web/HTTP | grep -iE 'cache-control|^age|etag|vary|x-cache'
Terminal
cache-control: public, max-age=3600
etag: "774a6a47d866c4ce93f51b6dc609b46d"
age: 968
x-cache: MISS, HIT, MISS
vary: Accept-Encoding
Text

Shared caches may store it for an hour. The copy that answered was 968 seconds old, so it stays fresh for another 2,632. The x-cache header shows several cache layers, one of which had it. Vary: Accept-Encoding tells caches to keep separate copies for gzip, Brotli and uncompressed clients.

A shared cache leaking an account page

To see how a leak happens, here’s a toy shared cache in Node: about 60 lines that follow a small part of RFC 9111. It keys entries by URL alone, as many CDN setups do by default, and its storage and freshness rules are these:

// On a response from the origin: may a shared cache keep it?
const cc = directives(fresh.headers['cache-control']);
const storable = !('private' in cc) && !('no-store' in cc);

// On a request: can the stored copy be served without asking the origin?
const lifetime = Number(d['s-maxage'] ?? d['max-age'] ?? 0);
if (hit && !('no-cache' in d) && age < lifetime) {
  res.writeHead(200, { ...hit.headers, age: String(age), 'x-cache': 'HIT' });
  return res.end(hit.body);
}
JavaScript

Behind it, an origin serves /account with the user’s name and balance, chosen by a session cookie. Alice requests the page, then Bob, with four different Cache-Control values on the origin’s response:

Cache-Control: max-age=60
  origin: GET /account for alice
alice got {"page":"/account","user":"alice","balance":1200}  [MISS, stored]
bob   got {"page":"/account","user":"alice","balance":1200}  [HIT]
Cache-Control: private, max-age=60
  origin: GET /account for alice
alice got {"page":"/account","user":"alice","balance":1200}  [MISS, not stored]
  origin: GET /account for bob
bob   got {"page":"/account","user":"bob","balance":35}  [MISS, not stored]
Cache-Control: no-cache
  origin: GET /account for alice
alice got {"page":"/account","user":"alice","balance":1200}  [MISS, stored]
  origin: GET /account for bob (revalidation)
bob   got {"page":"/account","user":"bob","balance":35}  [MISS, stored]
Cache-Control: no-store
  origin: GET /account for alice
alice got {"page":"/account","user":"alice","balance":1200}  [MISS, not stored]
  origin: GET /account for bob
bob   got {"page":"/account","user":"bob","balance":35}  [MISS, not stored]
Text

The first run is the incident. The developer added max-age=60 to make the account page feel faster, thinking of the browser. But max-age without private allows any cache to store the response, and the request’s Cookie header doesn’t change that. The spec holds shared caches back when a request carries an Authorization header, but cookies get no such protection. Bob received Alice’s balance from the cache, and the origin never saw his request.

private, max-age=60 keeps the speed-up in each user’s own browser and tells shared caches to stay out. no-store shuts every cache out.

Real CDNs often won’t cache HTML or responses with cookies unless configured to, which is how this mistake can sit unnoticed for months. Then someone adds a “cache everything” rule to cut origin load, and the origin’s headers become the only safeguard.

no-cache is not no-store

The third run deserves a closer look. With no-cache, the cache did store Alice’s page. When Bob asked, it revalidated: it sent the origin Alice’s ETag in If-None-Match. The origin built Bob’s page, saw a different ETag and returned a full 200 with his data. That worked only because the ETag was computed from each user’s content. If the origin had used a single ETag for the page template, it would have answered 304 Not Modified and the cache would have served Alice’s page to Bob.

So no-cache means “you may store this, but check with me before every use”. It’s the right policy for HTML that changes on deploy, where a cheap 304 beats re-downloading. It’s not a privacy control. Sensitive responses need private (if a copy in the user’s own browser is acceptable) or no-store (for things like banking pages on a shared computer).

Policies for common responses

Response Cache-Control Why
Fingerprinted asset like app.3f9a1c.js public, max-age=31536000, immutable the URL changes when the content does
HTML page no-cache, with an ETag always current after a deploy, cheap 304s
Public API listing public, s-maxage=60, stale-while-revalidate=30 CDN absorbs load; browsers ask the CDN
Logged-in API data private, no-cache the browser may keep it but must check it
Highly sensitive data no-store no copies anywhere

Two more points come up in interviews. First, Vary extends the cache key with request headers. Vary: Accept-Encoding is routine. Vary: Cookie technically separates users but splits the cache into one entry per session, so it rarely makes sense to use a shared cache at all for that content. Second, a long max-age can’t be taken back. Once a browser holds a copy fresh for a year, purging the CDN won’t reach it. That’s why long lifetimes go only on fingerprinted URLs, and why HTML, which points at those URLs, gets a short lifetime or no-cache.

Interview practice

The day after your team put a CDN in front of the whole site, a customer reports seeing another customer’s name in the page header. What happened, what do you do right now, and what do you change for the long term?

Answer

Most likely the CDN stored a personalized HTML response and served it to other users under the same URL key. That happens when the origin sends a cacheable header (max-age or s-maxage without private) or none at all with a CDN rule that caches everything, and the CDN ignores the session cookie when building its key. I’d confirm by requesting the page through the CDN with two different session cookies and checking the response headers for Age or a cache-hit marker.

Right now I’d purge the affected URLs and add a CDN rule to bypass the cache for HTML, or for any request with a session cookie, which stops the leak while the real fix goes in. I’d also treat it as a data exposure and loop in whoever handles security incidents.

For the long term, the origin should declare its intent on every response instead of relying on CDN defaults: private, no-cache or no-store for anything personalized, and long public lifetimes only on fingerprinted static assets. If the page shell is the same for everyone, I’d cache the shell publicly and load the personalized parts with a separate private request. Then I’d add an automated check that requests personalized pages through the CDN as two users and fails if they ever match.

Learn more

More in Networking

read ✓Networking · mid

Networking: HTTP Redirects

Test 301, 302, 303, 307 and 308 against a local Node server with curl and fetch, and see which codes turn a POST into a GET and why.

~6 min readread →
read ✓Networking · mid

HTTP ETags and Conditional Requests

Use ETags for cheap 304 revalidation and to stop lost updates with If-Match, shown with curl against a small server that two editors share.

~6 min readread →
read ✓Networking · mid

HTTP Connection Reuse and Latency

Measure what keep-alive saves, size a client connection pool with Little's law, and retry safely when a pooled socket was closed under you.

~6 min readread →
esc