HTTP Caching Headers: Cache-Control and ETag
HTTP caching looks like a pile of headers that interact unpredictably. It gets much smaller once you notice that a cache is only ever answering two questions, in order:
- May I reuse this without asking anyone? — freshness, answered by
Cache-Control. - If I must ask, can I ask cheaply? — validation, answered by
ETagorLast-Modified.
A response that answers only the first is uncacheable the moment it goes stale. One that answers only the second pays a round trip forever. Good caching answers both.
Question one: freshness
Cache-Control: public, max-age=3600
For one hour, any cache may serve this to anyone with no network request at all.
The fastest possible outcome, and the reason max-age is the header that
actually makes sites fast.
The directives worth knowing:
public/private— may a shared cache (CDN, proxy) store this, or only the user’s own browser? Anything personalised must beprivate, or one user’s page gets served to another. This is the directive that causes real incidents.max-age=N— seconds of freshness, for every cache.s-maxage=N— seconds of freshness for shared caches only; overridesmax-agethere. The lever for “cache hard at the CDN, briefly in the browser”.no-cache— store it, but revalidate before every reuse. Not “do not cache”.no-store— do not write it down anywhere. This is the one people mean when they sayno-cache.immutable— this will never change at this URL, so do not revalidate even when the user hits reload.
Question two: cheap validation
When freshness expires, the cache does not have to re-download. It asks whether what it holds is still good.
# first response
ETag: "7d3fa1"
# next request
If-None-Match: "7d3fa1"
# server, if unchanged
HTTP/1.1 304 Not Modified
A 304 carries no body. On a 200 KB bundle that saves nearly all the bytes —
but not the round trip, which on a mobile connection is most of the
perceived cost. Validation is a consolation prize; the goal is to not ask at all.
Last-Modified does the same job at one-second resolution and is weaker. Prefer
ETag where you can generate one cheaply — a content hash, or a version column.
The pattern that makes both questions disappear
For static assets, put a content hash in the filename:
/assets/app.4f9c2b1e.js
Cache-Control: public, max-age=31536000, immutable
Because the URL changes whenever the content changes, the old URL never needs invalidating and the new one is never in any cache. A year of caching with no revalidation and no staleness risk. Every modern build tool emits this shape; the only job left is serving the right header.
The HTML that references those assets gets the opposite treatment — short-lived or revalidated — because it is the thing that has to change to point at the new filenames.
The directive worth adopting deliberately
Cache-Control: public, max-age=60, stale-while-revalidate=600
For ten minutes past expiry, the cache serves the stale copy immediately and refreshes in the background. Users never wait for a revalidation; the cost is that some users see data up to a few minutes old.
For content where that is acceptable — most listings, articles, dashboards — this is close to a free latency win, and it also protects your origin from a thundering herd when a popular object expires.
Its sibling stale-if-error serves the stale copy when the origin is returning
errors, which turns an origin outage into slightly old content instead of a
broken page.
The header everyone forgets
Vary: Accept-Encoding, Accept-Language
Vary tells shared caches that the response depends on those request headers,
so they must key separately on them. Omit it when you serve different content
per language and a CDN will happily hand a German page to an English speaker.
Never put Vary: User-Agent in production unless you truly serve per-agent
markup — it fragments the cache into near-uselessness, because there are
effectively unlimited user agent strings.
What you have actually configured
Not “how long to cache”. You have configured how wrong you are willing to be, and for how long — which is the same bargain as every other cache, written in headers instead of code.
Assets with hashed names can be wrong for zero seconds, so they get a year.
An article can be wrong for a minute, so it gets a minute plus
stale-while-revalidate. A bank balance can never be wrong, so it gets
no-store and pays full price every time. State the tolerance first; the
headers follow from it.
Quick answers
- What is the difference between no-cache and no-store?
- no-store forbids storing the response at all — use it for sensitive data. no-cache allows storing but requires revalidation with the server before each reuse. Despite the name, no-cache still caches; it just never serves without asking first.
- What is the difference between max-age and s-maxage?
- max-age applies to every cache including the browser. s-maxage applies only to shared caches such as a CDN and overrides max-age there. It lets you keep a long CDN lifetime with a short browser lifetime.
- How does an ETag work?
- The server sends an ETag identifying the response version. On the next request the browser sends If-None-Match with that value, and if the resource is unchanged the server replies 304 Not Modified with no body — saving the transfer but not the round trip.
- How should I cache static assets like JS and CSS?
- Put a content hash in the filename and serve with Cache-Control:public, max-age=31536000, immutable. Because a change produces a new filename, the old file never needs invalidating and the browser never revalidates.
References
Related Discoveries
Lumi's weekly note
A short email when we publish something useful. No spam, unsubscribe anytime.