Start a project

Article · HTTP · Caching · Performance · 12 min read

HTTP caching headers explained

HTTP caching is the set of rules that tell a browser, a CDN, or another intermediary whether it may reuse a response, and when it must check with the origin again. Cache-Control, validators such as ETag and Last-Modified, and Vary are how you write those rules down. Get them right and repeat visits are cheap. Get them wrong and you either serve yesterday’s HTML after a deploy, or you force every visitor to download a stylesheet that has not changed in months.

On this page 10 sections
  1. What a cache is allowed to do
  2. Cache-Control, in plain language
  3. Validators: ETag and Last-Modified
  4. HTML, assets and APIs need different rules
  5. Vary, cookies and the cache that never hits
  6. CDNs, stale-while-revalidate and purging
  7. Fingerprinted assets versus “just lower the TTL”
  8. A default policy for a small site
  9. Key takeaways
  10. 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:

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:

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.

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:

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:

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=600

Alongside that:

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

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.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project