On this page 10 sections
- What a cache is allowed to do
- Cache-Control, in plain language
- Validators: ETag and Last-Modified
- HTML, assets and APIs need different rules
- Vary, cookies and the cache that never hits
- CDNs, stale-while-revalidate and purging
- Fingerprinted assets versus “just lower the TTL”
- A default policy for a small site
- Key takeaways
- Frequently asked questions
The Cache-Control Explainer on this site already walks the tokens. This guide is the mental model I use when I set a policy for HTML, for assets and for APIs, and the default I start from on a small site such as leej.uk.
What a cache is allowed to do
A cache stores a response and, later, may answer a request with that stored copy instead of asking the origin. There are usually at least two caches in play:
- The browser cache, private to one person and one profile.
- A shared cache, often a CDN in front of your origin, which may serve the same copy to many people.
A response is fresh while the policy says it may be reused without checking. After that it is stale. A stale copy might still be used, depending on the directives, or the cache may revalidate with the origin: “I have this version, is it still current?” The origin can say yes with 304 Not Modified, or send a new body.
Nothing in this system is obliged to store a response just because it could. Cache-Control says what is allowed, not what is guaranteed. A browser with a full disk, a user with “disable cache” open in DevTools, or a CDN you have not configured, will not behave like the tidy diagram in a blog post. Measure with the HTTP Header Inspector and, if you want to step through a policy, the HTTP Cache Simulator.
Caching is also not a substitute for a performance budget. A huge JavaScript file that is cached for a year is still a huge file on the first visit, and still a cost on the wire, as the gzip Size Estimator will remind you.
Cache-Control, in plain language
Cache-Control is the main policy header. The tokens I actually choose between, in ordinary words:
max-age=N— this response may be reused for N seconds without asking the origin again.s-maxage=N— the same idea, but only for shared caches such as a CDN. The browser still usesmax-ageor the other directives that apply to it.public— a shared cache may store this. Useful when you want a CDN to hold an anonymous page or an asset.private— only a browser (or another private cache) may store this, not a CDN. Use it for responses that are personal.no-store— do not store this at all. Use it for sensitive responses you would not want sitting on disk.no-cache— you may store this, but you must revalidate with the origin before you reuse it. The name is a well-known trap: it does not mean “do not cache”.must-revalidate— once the response is stale, it must be revalidated before it is used. It does not, by itself, mean “always revalidate while still fresh”.immutable— while this response is still fresh, it will not change, so do not bother revalidating it on reload. This is for fingerprinted assets, not for/styles.cssthat you overwrite in place.stale-while-revalidate=N— a cache may serve a stale copy for N seconds while it checks for a new one in the background.stale-if-error=N— a cache may serve a stale copy for N seconds if the origin is failing.
You combine a small number of these, not all of them. The Cache-Control Explainer is the place to paste a string and read it back. Official definitions live in the HTTP caching specifications and in MDN’s Cache-Control documentation; I am paraphrasing the behaviour you will use, not reprinting the RFC.
Expires still exists. If you already have Cache-Control with max-age, you do not need to lean on Expires, and the two should not disagree.
Validators: ETag and Last-Modified
Freshness says when a cache may skip the network. Validators say how a cache can ask “do I still have the right bytes?” without downloading the body again.
ETagis an opaque tag for this version of the representation, often a hash. A later request can sendIf-None-Matchwith that tag. If the origin still has the same version, it returns304.Last-Modifiedis a timestamp. The validator request usesIf-Modified-Since. It is coarser than a hash, and it can misfire if clocks or multi-server deploys are sloppy, but it is widely supported.
A strong ETag should change whenever the body a client would see changes. If you compute it from a file hash, that happens naturally. If you compute it from a clock, you will invalidate caches on every deploy of unchanged files, which is the same dishonest-date problem I refuse in a sitemap.
Validators only help when a request actually reaches a place that can compare them. If a CDN is still serving a fresh copy, it will not ask the origin at all. If you set no-cache on HTML, you are asking for that revalidation on each use, which is how you can cache HTML safely and still ship a typo fix quickly.
HTML, assets and APIs need different rules
One policy for the whole origin is how people get stuck with a stale article, or with a CSS file that is refetched every time.
HTML documents change when you publish, and the URL usually stays the same (/articles/example). They should be revalidated often, or be fresh only for a short time. On a small site I typically use no-cache (store, but revalidate) or a short max-age plus a CDN purge on deploy. public is reasonable if the page is the same for every anonymous visitor. private if a cookie personalises the HTML. no-store if the page is a private dashboard.
Fingerprinted static assets (/css/site.a1b2c3.css, /js/app.7f90.js, hashed images) can be cached for a long time, by browsers and by the CDN, because a new deploy will have a new URL. public, max-age=31536000, immutable is the usual policy. Do not use immutable on a filename you overwrite.
Unhashed assets (/favicon.ico, /css/site.css if you have not fingerprinted it yet) cannot be given a year-long freshness without a plan to rename or purge them. Use a short max-age, or start fingerprinting.
APIs depend on the data. A public, slowly changing read can use a short max-age or stale-while-revalidate. A user-specific or state-changing response should be private and often no-store or no-cache. Do not let a shared cache store one person’s basket and serve it to the next.
This split is the same discipline as maintaining an iOS app after launch: the thing that changes often (content) and the thing that is versioned (the binary, or here the hashed asset) do not share a release story.
Vary, cookies and the cache that never hits
Vary tells a cache that the response may differ based on a request header. Vary: Accept-Encoding is common and usually correct if you serve gzip or Brotli. Vary: Accept can be right for content negotiation. Vary: Origin often appears on CORS responses.
Vary: Cookie is how a CDN’s cache stops hitting. If every visitor has a unique cookie, and you vary on it, the shared cache has no one to share with. If only a few pages are personalised, do not put a site-wide Vary: Cookie on the HTML, and do not attach a unique cookie to every anonymous visit “for later”.
Other ways to miss the cache every time:
- Setting
Set-Cookieon responses you hoped the CDN would store. Many shared caches will refuse to store those, or will treat them as uncacheable, which is usually what you want for a session, and a surprise if a tracker cookie appears on a public CSS file. - Using a URL with a unique query string for the same asset (
?v=is fine if it is a deliberate cache-buster you control; a random analytics query on a CSS URL is not). - Sending
Cache-Control: no-storeor an equivalent on everything as a leftover from a staging host. - Letting the CDN’s own default (sometimes “cache everything for two minutes”, sometimes “respect origin”, sometimes “ignore query strings”) disagree with your origin headers, and never checking which one won.
Look at the response that came from the CDN, including any Age or CF-Cache-Status style header your provider documents, not only the policy you set on the origin.
CDNs, stale-while-revalidate and purging
A CDN is a shared cache you chose. It will only help if:
- The responses you want it to store are
publicand not personalised. - You can purge, or you have fingerprinted the files so you do not need to purge them.
- The origin headers and the CDN’s cache rules agree.
stale-while-revalidate is a good partner for HTML or for a public API at the CDN: visitors can be served a slightly old copy while the edge checks the origin. It is not a licence to forget deploys. When you ship a typo fix or a legal change, purge the HTML (or wait out a short max-age) rather than hoping a background revalidation has already happened everywhere.
stale-if-error can keep a public page up if the origin is briefly down. It can also keep a bad or private response around if you applied it too widely. Use it on content you would be willing to show for an extra window, not on account pages.
Purge by URL, on deploy, for the HTML and for any unhashed files you changed. Do not rely on “purge everything” as a daily habit if you can be more precise, and do not skip the purge because the assets are fingerprinted — the HTML still points at them and still needs to be updated.
Fingerprinted assets versus “just lower the TTL”
When an asset URL never changes, the only way to ship an update is to wait for freshness to end, or to purge. Teams then lower max-age to an hour, or a minute, and everyone pays a revalidation or a download far more often than the file changes. That is a poor trade, especially on mobile, and it is the sort of quiet waste I would also try to avoid when optimising battery in an app: do not redo work that has not changed.
Fingerprinting (a content hash in the filename, or a reliable query token that you only change when the bytes change) lets you set a long max-age and immutable. The HTML, which you already revalidate often, is what points at the new name after a deploy. That is the default I want on CSS, JavaScript and most images.
If you cannot fingerprint yet, a short max-age plus ETag revalidation is a tolerable interim. It is still better than no-store on a public stylesheet.
A default policy for a small site
This is the policy I start from on a small, mostly public site, and then I adjust with evidence. It is a default, not a standard.
# HTML (anonymous articles, home, tools chrome)
Cache-Control: public, max-age=0, must-revalidate
# equivalently, many teams send: no-cache
# Fingerprinted CSS, JS, fonts, images
Cache-Control: public, max-age=31536000, immutable
# Unhashed icons or legacy files, until they are named properly
Cache-Control: public, max-age=86400
# Private account HTML or JSON
Cache-Control: private, no-store
# Public, slowly changing JSON, if you have any
Cache-Control: public, max-age=60, stale-while-revalidate=600Alongside that:
- Send a strong
ETag(or a carefulLast-Modified) on HTML and on unhashed files, so revalidation is cheap. - Fingerprint assets in the build, and reference those names from the HTML in the same release, so you cannot ship new markup that still points at yesterday’s hashed file, or the reverse.
- Keep personalisation off the public HTML, or make those responses
private, so a CDN can cache the rest. - Purge HTML at the CDN on deploy, even if
max-ageis already zero, if the edge might be holding astale-while-revalidatecopy. - Check a sample URL after every release with the HTTP Header Inspector, once from the origin or worker, and once through the CDN.
These headers sit alongside your security headers and CSP; they should not be copied from a conflicting starter pack. A stale or missing cache policy can also show up as a slow LCP, so diagnose the Core Web Vital once you know whether the HTML was late or an asset was refetched. When something feels “stuck”, I look at these headers before I look at a service worker. A service worker is an additional cache, with its own rules, and it will fight an origin policy you have not read. Diagnose the HTTP cache first, the same way I would start with a recording in Instruments before rewriting the feature.
Key takeaways
- Caching rules say whether a browser or a shared cache may reuse a response, and when it must revalidate.
no-cachemeans revalidate, not “do not store”. - HTML and unhashed files need a short freshness or a revalidate policy. Fingerprinted assets can be
public, max-age=31536000, immutable. - Use
ETagorLast-Modifiedso revalidation can return304. Do not invent a new tag on every deploy of an unchanged file. - Be careful with
Varyand cookies, or a CDN will never hit. Personalised responses should beprivateorno-store. - Purge HTML on deploy, keep assets fingerprinted, and inspect the headers that the CDN actually served after each release.
If you want a second pair of eyes on a cache policy that is either always stale or never updates, tell me about it.
Frequently asked questions
What is the difference between Cache-Control and ETag?
Cache-Control sets the policy: whether a response may be stored, for how long it is fresh, and whether a stale copy may be used. ETag is a validator: a tag the cache can send back so the origin can say “unchanged” with a 304 instead of resending the body. You usually want both, doing different jobs.
Should HTML be cached in the browser?
Yes, often, but not as a year-long fresh copy on the same URL. Let the browser store the HTML and revalidate it (no-cache, or max-age=0, must-revalidate), or give it a very short max-age and purge the CDN when you publish. The aim is cheap repeat views without serving a previous release after you have shipped a new one.
What does no-cache actually mean?
It means the cache may store the response, but it must check with the origin before using it. It does not mean “do not cache”. If you truly do not want the response stored, use no-store.
How does Vary affect a cached response?
Vary lists request headers that change which stored copy may be used. Vary: Accept-Encoding is normally correct. Vary: Cookie on a public page often makes a shared cache useless, because every visitor looks unique. Only vary on headers that genuinely change the response.
How do I cache CSS and JS but still ship updates?
Put a content hash in the filename, set Cache-Control: public, max-age=31536000, immutable on those files, and point the HTML at the new name when the bytes change. The HTML itself should revalidate or purge on deploy. If you overwrite /app.js in place, you cannot safely give it a long freshness, and you will need a short TTL or a purge every time it changes.
Written by Lee Johnson. Published 10 November 2026.