The fastest request is the one that is never made. HTTP caching lets the browser, and intermediate servers such as CDNs, reuse responses they have already received instead of downloading them again. Configured well, it makes repeat visits instant and lightens the load on the server. Configured badly, it causes the opposite and more irritating problem: users seeing an old version of the site after an update.
Two mechanisms: freshness and validation
HTTP caching is based on two distinct ideas.
- Freshness: for a certain period the cached response is considered valid and is used without contacting the server.
- Validation: once freshness expires, the browser asks the server "has anything changed?". If not, the server replies
304 Not Modifiedwith no body, and the browser reuses the local copy.
Validation still makes a request, but a very light one. Freshness avoids the request altogether.
The Cache-Control header
This is the main header for controlling caching. The most important directives:
| Directive | Meaning |
|---|---|
max-age=N |
the response is fresh for N seconds |
s-maxage=N |
like max-age, but only for shared caches (CDNs, proxies) |
no-cache |
may be stored, but must always be validated before use |
no-store |
do not store at all |
public |
may also be stored by shared caches |
private |
only in the user's browser cache |
immutable |
the content will never change while fresh |
must-revalidate |
once stale, do not use the copy without validating it |
stale-while-revalidate=N |
for N seconds use the stale copy while refreshing it in the background |
The most common misunderstanding concerns no-cache: it does not mean "do not cache", but "cache it and always ask for confirmation". To forbid storage you use no-store.
Validation with ETag and Last-Modified
To validate, the server provides an identifier for the version:
HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "5f3a-1b2c"
On the next request the browser sends it back:
GET /index.html
If-None-Match: "5f3a-1b2c"
If the ETag matches, the response is a 304 with no content. As an alternative to ETag there is Last-Modified, with the conditional request If-Modified-Since, based on the modification date. Many servers and static hosts generate both automatically.
The strategy that works: hashed assets and always-validated HTML
The underlying problem is this: you want CSS, JavaScript and images to stay in cache for a long time, but you also want an update to arrive immediately. The standard solution is versioning through the file name.
Build tools generate names that contain a fingerprint (hash) of the content:
<link rel="stylesheet" href="/assets/style.3f9a2c1b.css">
<script src="/assets/app.8d41e0a7.js" defer></script>
When the file changes, the hash changes too, and so does the URL. The old cached file is no longer requested: the HTML already points to the new one. That is why hashed assets can have a very long cache lifetime:
Cache-Control: public, max-age=31536000, immutable
HTML, on the other hand, is the entry point that contains the references to the assets: it must always be up to date. For HTML it is best to use:
Cache-Control: no-cache
or a very short freshness period. That way every visit checks whether the page has changed (with a light request thanks to the ETag) and, if it has, downloads the new HTML with the new references.
Assets without a hash
If a file has a fixed name, such as /logo.png or /style.css, a one-year cache is dangerous: after a change users would keep seeing the old version. For these files use moderate freshness (a few hours, for example) combined with validation, or introduce versioning.
Summary by resource type
| Resource | Recommended Cache-Control |
|---|---|
| HTML | no-cache (with ETag) |
| CSS/JS/fonts with a hash in the name | public, max-age=31536000, immutable |
| Images with a fixed name | public, max-age=86400 plus validation |
| Personal API responses | private, no-cache or no-store |
| Sensitive data (private area) | no-store |
Shared caches and personal data
A CDN or proxy serves the same copy to several users. If a response contains personal data (the user's name, the contents of their cart) and is stored by a shared cache, another user might receive it. For user-dependent responses always use private or no-store, and check the CDN configuration, which sometimes applies rules of its own.
The Vary header tells caches which request headers change the response, for example Vary: Accept-Encoding for compressed versions. Use it sparingly: values such as Vary: Cookie drastically reduce the effectiveness of shared caching.
How to check
- In the developer tools, Network tab, look at the size column: "memory cache" or "disk cache" means a locally served response, while
304means a successful validation. - Temporarily untick the developer tools option that disables the cache, or you will not see the real behaviour.
- Check headers with
curl -I, including for responses served by the CDN.
In short
- Caching combines freshness (no request) and validation (a light request answered with 304).
no-cachemeans "always validate",no-storemeans "do not store".- Give assets with a hash in their name a one-year cache with
immutable. - Keep HTML always validated, so updates arrive straight away.
- Do not let shared caches store responses containing personal data.