Skip to content

5.6.2 — Caching, Conditional Requests & Compression

The fastest request is the one that never happens. The second fastest is the one that returns 304 with no body.

Both are HTTP features, and both are configured with a handful of headers that people copy from Stack Overflow and then cannot debug. This page is what each one actually does.

1. The two questions every cache asks

A cache — the browser's, a CDN's, a proxy's — is answering two separate questions, and keeping them apart is the whole subject.

Question one: may I reuse this without asking? Answered by Cache-Control and the concept of freshness. If the stored copy is still fresh, the cache serves it with no network request at all.

Question two: my copy is stale — has it actually changed? Answered by a conditional request carrying ETag or Last-Modified. The cache asks the server, and the server may reply 304 Not Modified with no body, which means one small round trip instead of transferring a megabyte.

The performance difference between those two is enormous, and it maps directly to the header you choose:

  • Fresh: 0 ms, no packet leaves the machine.
  • Stale but unchanged: one round trip, a few hundred bytes.
  • Stale and changed: one round trip plus the full transfer.

2. Cache-Control, directive by directive

Cache-Control is the modern header and it overrides the older Expires. The directives that matter:

max-age=3600 — fresh for 3,600 seconds from when it was received. This is the main one.

s-maxage=86400 — the same, but only for shared caches (CDNs and proxies), overriding max-age for them. This is how you tell a CDN to hold something for a day while browsers hold it for an hour, which is a common and useful split.

no-cache — a badly named directive. It does not mean "do not cache". It means "cache it, but revalidate with the server before every use". Every request becomes conditional, and the server usually answers 304. Use it for content that changes unpredictably but is often unchanged, such as an HTML page.

no-store — this is the one that actually means do not cache. Nothing is written to disk or memory. Use it for genuinely sensitive responses: bank balances, personal data, anything you would not want recoverable from a shared machine's disk.

private — may be cached by the browser but not by a shared cache. Essential for anything personalised. Getting this wrong is a serious bug, because a CDN caching one user's personalised page and serving it to another is a data leak, and it has happened to large sites.

public — explicitly cacheable by shared caches even when heuristics would say otherwise.

must-revalidate — once stale, the cache must not serve it without checking, even if the server is unreachable. Without this, some caches serve stale content when the origin is down; with it, they return an error instead.

immutable — this will never change at this URL, so do not even revalidate on a reload. Only correct for versioned URLs (section 5).

stale-while-revalidate=60 — serve the stale copy immediately and refresh it in the background. The user gets an instant response and the next user gets fresh content. Genuinely underused.

stale-if-error=86400 — if the origin returns an error, keep serving the stale copy for up to a day. This is a resilience feature, not a performance one, and it means a CDN can keep a site up through an origin outage.

The two configurations to have memorised:

http
# Versioned static asset — the filename contains a hash
Cache-Control: public, max-age=31536000, immutable

# HTML page — must always be current
Cache-Control: no-cache

One year and immutable for the first, because a new build produces a new filename, so the old URL genuinely never changes. no-cache for the second, because the HTML is what tells the browser which asset filenames to fetch, so it must never be stale — but it can and should be revalidated cheaply.

3. ETags and conditional requests

An ETag is an opaque identifier the server assigns to a specific version of a resource. It is usually a hash of the content, but the client must treat it as meaningless — its only property is that it changes when the content changes.

http
# First request
GET /api/orders/8891 HTTP/1.1

HTTP/1.1 200 OK
ETag: "a3f8c1"
Cache-Control: no-cache
Content-Length: 4096
{...}

# Later, the cache revalidates
GET /api/orders/8891 HTTP/1.1
If-None-Match: "a3f8c1"                        ← (1)

HTTP/1.1 304 Not Modified                      ← (2)
ETag: "a3f8c1"
                                               ← (3) no body
  1. "Give me this only if its version is not a3f8c1."
  2. Unchanged, so nothing is sent.
  3. The saving is the body. The round trip still happens, but 4 KB becomes about 150 bytes.

Last-Modified and If-Modified-Since do the same job with a timestamp, and are weaker for two reasons: one-second granularity, so two changes within the same second are indistinguishable; and a file whose content is unchanged but which was rewritten gets a new timestamp and forces a pointless transfer. ETags have neither problem. Send both when you can — clients use whichever they support.

Strong versus weak. ETag: "a3f8c1" is strong — byte-identical. ETag: W/"a3f8c1" is weak — semantically equivalent but possibly not byte-identical, which is right when a response is compressed differently or contains a timestamp that does not matter. Only a strong ETag may be used for range requests, because resuming a partial download requires the bytes to match exactly.

Now the second use of ETags, which matters more than caching. The same identifier gives you optimistic concurrency control:

http
PUT /api/orders/8891 HTTP/1.1
If-Match: "a3f8c1"                             ← (1)
{...}

HTTP/1.1 412 Precondition Failed               ← (2)
  1. "Apply this only if the current version is still a3f8c1."
  2. Someone else changed it first, so the update is rejected rather than silently overwriting their work.

This is the lost update problem solved at the protocol level. Two users load a record, both edit, both save — without this, the second save silently destroys the first. If-Match turns it into a 412 the client can handle by re-reading and merging. Chapter 9.6.3 develops the pattern, and Chapter 7.4 covers the database equivalent; the point here is that HTTP has this built in and almost nobody uses it.

4. Vary: the header that causes the worst caching bugs

A cache keys entries on the URL. But a server may return different content for the same URL depending on a request header — compressed or not, English or French, mobile or desktop.

The Vary header tells the cache which request headers are part of the key:

http
Vary: Accept-Encoding
Vary: Accept-Encoding, Accept-Language

Getting this wrong produces real, visible incidents:

Omitting Vary: Accept-Encoding means a cache may store the gzipped response and serve it to a client that did not ask for compression, which sees binary garbage.

Omitting Vary on a personalised response means one user's page is served to another. This is the serious one. Cache-Control: private is the primary defence; Vary alone is not enough, because a CDN keyed on a cookie value would then store a separate copy per user, which is not caching at all.

Over-using Vary destroys the hit rate. Vary: User-Agent is the classic mistake — there are millions of distinct user-agent strings, so each becomes its own cache entry and the hit rate collapses to near zero. If you find yourself varying on something high-cardinality, the answer is usually to serve one response and adapt on the client instead.

5. Cache busting: why the filename has a hash in it

You set max-age=31536000 on a JavaScript file. You deploy a fix. Every returning visitor keeps the old file for up to a year, and there is no way to reach them — you cannot invalidate a browser cache you do not control.

The solution is not to shorten the TTL. It is to change the URL:

/static/app.a3f8c1d2.js          ← the hash comes from the file's content

A new build produces different content, therefore a different hash, therefore a different URL, therefore a guaranteed cache miss. The old URL is never requested again. This is why every bundler emits hashed filenames, and it is what makes the one-year immutable setting safe rather than reckless.

The HTML must not be cached this way, because it is what contains the asset filenames. Hence the split in section 2: HTML on no-cache, hashed assets on a year.

?v=2 query strings are a weaker variant. Some intermediate caches historically ignored query strings when caching, and you have to remember to bump the number. A content hash is automatic and cannot be forgotten.

6. Compression: shrinking what does travel

Compression is negotiated. The client says what it can decode; the server picks one and says which it used.

http
Accept-Encoding: gzip, deflate, br, zstd       ← client
Content-Encoding: br                           ← server's choice
AlgorithmRatio on textCompress speedNotes
gzipbaselinefastuniversal since the 1990s
br (Brotli)~15–20% betterslower at high levelsnow supported everywhere
zstdsimilar to Brotlimuch fasternewer, growing support
deflatesame as gzipfastambiguously specified; avoid

Brotli's real advantage is a built-in dictionary. It ships with a pre-trained dictionary of common web strings — <!DOCTYPE html>, function, background-color and thousands more — so small files compress far better than gzip can manage, since gzip must build its dictionary from the file itself. On a 2 KB CSS file the difference is substantial; on a 2 MB file it is marginal.

The asymmetry to exploit: compression is slow, decompression is fast. So static assets should be compressed once at build time at the maximum level and served pre-compressed, while dynamic responses are compressed on the fly at a middling level. Compressing every response at Brotli level 11 on request is a real way to burn a server's CPU.

Do not compress what is already compressed. JPEG, PNG, MP4, and any zip or gzip archive are already at their entropy limit (Chapter 1.8). Compressing them wastes CPU and occasionally makes them slightly larger.

And the security constraint that surprises people: do not compress a response that mixes a secret with attacker-controlled content. This is the BREACH attack. Compression works by finding repetition, so if an attacker can inject text into a page that also contains a secret — a CSRF token, say — they can watch the compressed response size. When their guess matches part of the secret, the repetition compresses better and the response shrinks by a byte. Guessing character by character extracts the secret through a side channel, without ever decrypting anything. The response size leaks information even though the content is encrypted.

The defences are to not reflect user input into a page containing secrets, to mask tokens per response so they never repeat, or to disable compression on such responses. Chapter 8.5 covers it properly, and Chapter 9.9.5 covers the Express configuration.

Content-Encoding versus Transfer-Encoding. Content-Encoding is a property of the resource — the client stores it compressed and it survives caching. Transfer-Encoding: gzip applies only to one hop and is stripped by the next. In practice you almost always want Content-Encoding; the distinction matters mainly when debugging a proxy.

What the interviewer will push on

"What is the difference between no-cache and no-store?" no-cache means cache it but revalidate every time — you still get 304s and save the body. no-store means never write it down at all. The naming is unfortunate and it is asked precisely because so many people have it backwards.

"How do you cache a JavaScript bundle for a year and still ship a fix today?" Put a content hash in the filename, so a new build is a new URL and the old one is simply never requested. Then note that the HTML referencing it must be no-cache, because it is the thing that carries the new filenames.

"What does ETag do besides caching?" Optimistic concurrency: If-Match turns a lost update into a 412 Precondition Failed. Volunteering this is a strong signal, because most people only know the caching half.

"When is Vary needed and when is it harmful?" Needed whenever the response depends on a request header — Accept-Encoding at minimum. Harmful when the header is high-cardinality: Vary: User-Agent gives you a cache with a hit rate near zero.

"A CDN served one user's account page to another user. What went wrong?" A personalised response cached without Cache-Control: private, and probably without correct Vary. This is a real class of incident and knowing the header names is the difference between fixing it and guessing.

"Should you gzip everything?" No — not already-compressed formats, and not responses that mix a secret with reflected user input, because of BREACH. Explaining the size-side-channel mechanism rather than just naming the acronym is what makes the answer land.

"304 versus 200 from cache — which is faster and why?" 200 from cache is faster: zero network. A 304 still costs a full round trip and saves only the body. That is the entire reason to use max-age for assets rather than relying on revalidation.

One thing to volunteer: mention stale-while-revalidate and stale-if-error. The first gives users an instant response while refreshing in the background; the second lets a CDN keep serving a site through an origin outage. They are cheap to add, widely supported, and the second in particular is a resilience feature that most people never turn on.

Recall

  • A cache answers two questions: may I reuse this without asking (freshness, Cache-Control) and has it changed (a conditional request with ETag, answered by 304). Fresh costs 0 ms; revalidation costs a round trip and saves only the body.
  • no-cache means revalidate every time, not "do not cache" — no-store is the one that means that. private keeps a response out of shared caches and its absence is how a CDN leaks one user's page to another.
  • Two configurations cover most cases: hashed assets get public, max-age=31536000, immutable; HTML gets no-cache because it carries the asset filenames.
  • ETag also gives optimistic concurrency via If-Match412 Precondition Failed, which solves the lost update problem at the protocol level.
  • Vary adds request headers to the cache key. Omit Accept-Encoding and clients get garbage; vary on User-Agent and the hit rate collapses.
  • Cache busting changes the URL, not the TTL — a content hash in the filename makes a one-year immutable cache safe.
  • Compress with Brotli or zstd over gzip (Brotli ships a pre-trained web dictionary, which is why small files gain most); compress static assets once at build time; never compress already-compressed formats; and never compress a response mixing a secret with reflected input, because BREACH reads the secret from the response size.
  • stale-while-revalidate gives instant responses; stale-if-error keeps a site up through an origin outage.

Self-test: Which of no-cache and no-store still produces 304s? · Why must the HTML not be cached for a year when the assets are? · What does If-Match prevent that If-None-Match does not? · Why is Vary: User-Agent a mistake? · Explain how BREACH extracts a secret without decrypting anything.

Next: 5.6.3 covers the header that turned a stateless protocol into one with logins, shopping baskets and thirty years of tracking — the cookie, attribute by attribute, and why third-party ones are being switched off.