HTTP cache headers are a contract you are signing with every browser
Every response you send carries an implicit answer to “how long may this be
reused?” — and if you do not set the caching headers deliberately, the browser and
any CDN in between guess, usually badly. Cache-Control, ETag, and friends are
not obscure server trivia; they are a contract you sign with every client about
how long to trust a response and how to check whether it is still good. Get the
contract wrong in one direction and users see stale files after a deploy; get it
wrong in the other and you throw away free speed by re-downloading things that never
changed. Getting it right is one of the cheapest performance wins available.
Cache-Control sets the freshness lifetime
Cache-Control is the main clause of the contract. max-age says how many seconds
the response may be reused without asking; no-cache means “you may store it, but
revalidate before every use”; no-store means “never keep it.” The trick is that
different resources want different terms:
# a hashed, immutable asset: cache hard, forever — the filename changes when it does
Cache-Control: public, max-age=31536000, immutable
# an HTML page that must reflect the latest deploy: store, but check every time
Cache-Control: no-cache
immutable on a content-hashed file (app.9f3c1.js) is the single biggest win:
the browser reuses it with no request at all until the hash — and thus the URL —
changes.
ETag makes revalidation nearly free
When a response is stale (or no-cache), the browser does not blindly re-download.
It sends the ETag it stored via If-None-Match, and the server compares: if
unchanged, it replies 304 Not Modified with no body, so the browser reuses
its copy for the cost of a tiny round-trip:
# first response
HTTP/1.1 200 OK
ETag: "abc123"
# later, browser revalidates
GET /data If-None-Match: "abc123"
HTTP/1.1 304 Not Modified ← no body sent; browser reuses its cached copy
You pay for headers, not for the payload — which for a large unchanged file is almost all the savings of a full cache hit.
The two failure modes, and the pattern that avoids both
Bad caching fails in two directions. Cache HTML too long and users run an old app
after you deploy (the “hard refresh fixes it” bug). Cache assets too little and you
re-download megabytes that never changed. The standard pattern resolves both:
content-hash your static assets and cache them immutable forever, while
serving HTML with no-cache so it always revalidates. A deploy changes the
asset filenames, the fresh HTML references the new names, and users get the update
instantly and keep every unchanged asset from cache. The contract is only
dangerous when you sign it by accident; sign it deliberately — long for hashed
assets, revalidate for HTML, no-store for anything private — and caching becomes
pure upside. The cache-headers-basics exercise walks through choosing the right
clause per resource type, which is where this stops being header trivia and becomes
a deploy strategy.