⌨️ Web development

HTTP caching: Cache-Control, ETag and asset versioning

How HTTP caching works: Cache-Control, validation with ETag, hashed file names and a safe caching strategy for HTML, CSS, JavaScript and APIs.

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.

  1. Freshness: for a certain period the cached response is considered valid and is used without contacting the server.
  2. Validation: once freshness expires, the browser asks the server "has anything changed?". If not, the server replies 304 Not Modified with 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

  1. In the developer tools, Network tab, look at the size column: "memory cache" or "disk cache" means a locally served response, while 304 means a successful validation.
  2. Temporarily untick the developer tools option that disables the cache, or you will not see the real behaviour.
  3. 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-cache means "always validate", no-store means "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.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. A computer science graduate, he combines rigorous technical analysis with attention to the people who use technology.

Read also