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=31536000is one year.no-cache: store it, but revalidate with the server before every use. It is emphatically not a ban on caching — it behaves likemax-age=0plus mandatory revalidation. If nothing changed, the server answers304and 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.privatevspublic:privaterestricts storage to the end user's browser and forbids shared caches (CDNs, proxies) — mandatory for per-user responses.publicexplicitly allows shared caches; in practice you mostly need it to make responses toAuthorizationrequests cacheable, since plain responses withmax-ageare generally shared-cacheable already.s-maxage=N: a freshness lifetime that only shared caches obey. Browsers followmax-age, CDNs follows-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.
- Hashed assets:
Cache-Control: public, max-age=31536000, immutable— one year of caching, and no revalidation traffic even on reload. - 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 type | Recommended Cache-Control | Why |
|---|---|---|
Hashed assets (app.3f9a2c.js) | public, max-age=31536000, immutable | URL changes whenever content changes, so forever-caching is safe |
| HTML documents | no-cache (+ ETag) | Deploys show up instantly; revalidation costs a 304 |
| Images/fonts without hashes | public, max-age=86400, stale-while-revalidate=604800 | One day fresh, then a week of serve-stale-and-refresh |
| Public API responses | public, max-age=60, s-maxage=300 | Browsers 1 min, CDN 5 min — the CDN can be purged on demand |
| Per-user data | private, no-cache | Never stored in shared caches; browser revalidates before reuse |
| Sensitive data (financial, medical) | no-store | No copy written anywhere, ever |
Three mistakes that cause real incidents
- Treating
no-cacheas a storage ban: put onlyno-cacheon sensitive data and the response still gets written to disk — only revalidation is enforced. Useno-storewhen storage itself is the problem. Conversely, usingno-storewhere you merely wanted freshness throws away 304 reuse and makes every request a full download. - Long
max-ageon HTML: giveindex.htmlamax-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
privatewiths-maxage:s-maxageexists solely for shared caches, andprivateforbids shared caches from storing the response — the two contradict each other and thes-maxageis dead weight. Per-user responses getprivatealone; CDN-cacheable ones getpublic(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.
| Path | Header |
|---|---|
/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 data | Cache-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?
We deployed but users still see the old site. Why?
When should I add immutable?
What happens if I set both max-age and s-maxage?
Doesn't must-revalidate mean the cache revalidates on every request?
Tools to use with this guide
Related guides
- Slow Site? Diagnosing and Fixing TTFB Step by StepWhere time-to-first-byte goes — DNS, TLS, server, database — and how to diagnose and cut each stage.
- Enabling gzip and Brotli: Cut Transfer Size by 70%What compressing text resources buys you, nginx/Apache config, verification and common mistakes.
- Core Web Vitals (LCP, CLS, INP) Improvement ChecklistWhat LCP, CLS and INP measure, and a practical checklist to improve each score.