OneWebDesk

Cache-Control Done Right: Making Browser Caching Work

What max-age, no-cache, no-store and immutable really mean, with recommended policies per resource type.

Cache-Control is the highest-leverage header in web performance. Get one line right and returning visitors load your CSS, JS and images straight from disk with zero network round trips, while your origin traffic and CDN bill drop sharply. Get it wrong and you ship a deploy nobody sees for a day — or worse, private data ends up stored in a shared cache.

The catch is that the directive names lie to your intuition. no-cachedoes not mean “don't cache”, and must-revalidatedoes not mean “revalidate every time”. This guide fixes those misconceptions one by one, gives you a recommended policy table per resource type, and ends with a complete header set you can copy for a typical site. Whenever a combination feels ambiguous, assemble it interactively with the Cache-Control builder instead of guessing.

What each directive really means — no-cache is not “don't cache”

Directives answer three separate questions: how long is the response fresh, who is allowed to store it, and what happens once it goes stale.

  • max-age=N: the response counts as fresh for N seconds after it was received. While fresh, the browser reuses it without ever contacting the server.max-age=31536000 is one year.
  • no-cache: store it, but revalidate with the server before every use. It is emphatically not a ban on caching — it behaves like max-age=0 plus mandatory revalidation. If nothing changed, the server answers 304 and the cached body is reused, so the check costs a few hundred bytes.
  • no-store: this is the real “do not cache”. Nothing — browser, proxy or CDN — may write the response anywhere. Reserve it for bank balances, medical records and other data that must leave no trace on disk.
  • private vs public: privaterestricts storage to the end user's browser and forbids shared caches (CDNs, proxies) — mandatory for per-user responses. public explicitly allows shared caches; in practice you mostly need it to make responses to Authorization requests cacheable, since plain responses withmax-age are generally shared-cacheable already.
  • s-maxage=N: a freshness lifetime that only shared caches obey. Browsers follow max-age, CDNs follow s-maxage, letting you keep browsers on a short leash while the CDN caches longer.
  • immutable: a promise that the bytes at this URL will never change, so the browser should skip revalidation even when the user hits reload. Only correct for content-hashed filenames.
  • must-revalidate: not “always revalidate”. It only kicks inafter expiry: a stale copy must never be served without a successful check against the server (some caches would otherwise serve stale during origin outages).
  • stale-while-revalidate=N: for N seconds after expiry, serve the stale copy instantly and refresh it in the background, so the next request gets the new version. It buys perceived speed without giving up freshness; the SWR planner helps you pick sane windows.

How revalidation works — ETag and 304 Not Modified

The reason no-cache and expired entries stay cheap is the conditional request. The server tags its first response with ETag: "abc123" (a fingerprint of the body) or a Last-Modified date. On the next request the browser sendsIf-None-Match: "abc123". If the content is unchanged, the server replies304 Not Modified with no body at all, and the browser keeps using its stored copy. A 100 KB HTML page revalidates for a few hundred bytes.

This is why pairing no-cache (or max-age=0) with a validator is essential: with no ETag or Last-Modified, every check degenerates into a full 200 download. Check which cache headers and validators your pages actually emit with the HTTP headers inspector — what your framework sends is often not what you configured.

The core pattern — hash the assets, revalidate the HTML

Modern caching strategy boils down to two lines. Build tools (webpack, Vite, etc.) embed a hash of the file's content into its name, producing app.3f9a2c.js. Change a single byte and the filename changes too — so the content behind any given URL is permanent, and caching it for a year is perfectly safe.

  1. Hashed assets: Cache-Control: public, max-age=31536000, immutable — one year of caching, and no revalidation traffic even on reload.
  2. HTML entry points: Cache-Control: no-cache — always checked with the server, but cheaply via 304. Because the HTML references the new hashed filenames, keeping just the HTML fresh makes every deploy visible immediately.

Think of the HTML as the gatekeeper and hashed assets as the warehouse: the gatekeeper is verified on every visit, while warehouse items never change without getting a new label, so they need no inspection.

Resource typeRecommended Cache-ControlWhy
Hashed assets (app.3f9a2c.js)public, max-age=31536000, immutableURL changes whenever content changes, so forever-caching is safe
HTML documentsno-cache (+ ETag)Deploys show up instantly; revalidation costs a 304
Images/fonts without hashespublic, max-age=86400, stale-while-revalidate=604800One day fresh, then a week of serve-stale-and-refresh
Public API responsespublic, max-age=60, s-maxage=300Browsers 1 min, CDN 5 min — the CDN can be purged on demand
Per-user dataprivate, no-cacheNever stored in shared caches; browser revalidates before reuse
Sensitive data (financial, medical)no-storeNo copy written anywhere, ever

Three mistakes that cause real incidents

  • Treating no-cache as a storage ban: put only no-cache on sensitive data and the response still gets written to disk — only revalidation is enforced. Useno-store when storage itself is the problem. Conversely, using no-store where you merely wanted freshness throws away 304 reuse and makes every request a full download.
  • Long max-age on HTML: give index.html amax-age=86400and browsers will serve yesterday's HTML for a full day without asking your server. That stale HTML may reference old hashed assets that your deploy already deleted, producing 404s and a broken page. This is the number one cause of “we deployed but users still see the old site”.
  • Combining private with s-maxage: s-maxage exists solely for shared caches, and private forbids shared caches from storing the response — the two contradict each other and the s-maxage is dead weight. Per-user responses getprivate alone; CDN-cacheable ones get public (or nothing) pluss-maxage.

Worked example — a complete header set for a typical site

For the common setup of a static front end plus an API behind a CDN, these five rules are close to a universal answer.

PathHeader
/assets/* (hashed filenames)Cache-Control: public, max-age=31536000, immutable
/*.html, /Cache-Control: no-cache + ETag
/images/*Cache-Control: public, max-age=86400, stale-while-revalidate=604800
/api/public/*Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60
/api/me and other per-user dataCache-Control: private, no-cache

After rolling this out, verify what each path actually returns with the HTTP headers inspector, and when you tweak a policy, compose the header string with the Cache-Control builder so conflicting directives never slip through. If you are adding SWR to API responses, simulate the fresh and grace windows first with the stale-while-revalidate planner.

Frequently asked questions

What is the difference between no-cache and no-store?
no-cache allows the response to be stored but forces a revalidation with the server before each reuse; if nothing changed, a tiny 304 lets the cached copy be reused. no-store is the actual prohibition: nothing may be written to any cache at all. Use no-cache for always-fresh-but-fast content, and no-store only for sensitive data that must leave no trace.
We deployed but users still see the old site. Why?
Almost always because the HTML itself carries a long max-age. Browsers will not ask your server again until it expires, so they keep rendering the old HTML — which may even reference deleted assets and break. Serve HTML with no-cache (or max-age=0) so it revalidates on every load, and reserve long max-age for content-hashed assets only.
When should I add immutable?
Only when the URL changes whenever the content changes — that is, filenames containing a content hash like app.3f9a2c.js. With immutable, the browser skips conditional revalidation requests even on reload, cutting request volume. Never use it on files that are updated in place under the same URL.
What happens if I set both max-age and s-maxage?
Private caches such as browsers use max-age, while shared caches such as CDNs prefer s-maxage. With max-age=60, s-maxage=300, browsers cache for one minute and the CDN for five. Since you can purge a CDN at deploy time, giving it a longer lifetime than browsers is a common pattern. Just never combine s-maxage with private — they contradict each other.
Doesn't must-revalidate mean the cache revalidates on every request?
No. must-revalidate has no effect while the response is still fresh. It only forbids serving the copy after expiry without a successful check against the server, closing the loophole where some caches serve stale content during origin outages. If you want revalidation on every use, the correct directive is no-cache.

Tools to use with this guide

Related guides