Guide
7 min read

HTTP caching headers explained: Cache-Control, ETag, and Last-Modified

Caching is one of the highest-leverage performance wins available to web developers. This guide explains the headers that control it, how browsers and CDNs interpret them, and the common mistakes that quietly break caching in production.

HTTP Status Codes

Look up any HTTP status code and its meaning

Open tool

Why caching matters

Every time a browser makes an HTTP request, it takes time: DNS lookup, TCP handshake, TLS negotiation, server processing, and data transfer. Caching skips some or all of that by reusing a previously fetched response.

There are three layers of cache between your server and your user: the browser cache (private to that user), a CDN or reverse proxy (shared across all users), and intermediate proxies that ISPs or corporate networks sometimes operate. HTTP caching headers control how all three behave.

Every cache must answer two questions: is this response allowed to be cached at all? And if it is, is the cached copy still fresh enough to serve?

Cache-Control directives

Cache-Control is the primary caching header, introduced in HTTP/1.1. A response can include multiple directives separated by commas, e.g. Cache-Control: public, max-age=3600.

DirectiveWhat it means
max-age=NCache the response for N seconds before considering it stale.
no-cacheCache the response, but revalidate with the server before using it.
no-storeDo not cache the response at all. Every request hits the server.
publicResponse may be cached by any cache, including CDNs.
privateResponse is specific to one user and must not be cached by shared caches.
must-revalidateOnce stale, the cache must revalidate with the server before using the cached copy.
immutableThe response will never change. Skip revalidation even after max-age expires.
stale-while-revalidate=NServe the stale response immediately while revalidating in the background for up to N seconds.

ETag and conditional requests

An ETag is a server-generated identifier (typically a hash of the response body) that changes whenever the resource changes. When combined with no-cache, it makes revalidation very efficient: the browser only downloads the body if it actually changed.

  1. 1First request: server responds with the body and ETag: "abc123". Browser caches both.
  2. 2Next request: browser sends If-None-Match: "abc123" to the server.
  3. 3If unchanged: server replies 304 Not Modified with no body. Browser uses the cached copy.
  4. 4If changed: server replies 200 OK with the new body and a new ETag.

ETags save bandwidth (the body is not re-sent) but not latency (the round-trip still happens). For zero-latency caching, use max-age with content hashing so the browser never needs to ask.

Last-Modified and If-Modified-Since

Last-Modified is an older alternative to ETag. The server sends the timestamp when the resource was last changed. On subsequent requests, the browser sends If-Modified-Since: <that timestamp> and the server replies 304 or 200 accordingly.

Last-Modified is less precise than ETag: two resources that happen to regenerate at the same second look identical even if their content differs. Prefer ETags when your server can generate them cheaply. Many servers send both.

The Vary header

Vary tells caches to store separate copies of a response based on the value of a specific request header. Without it, a CDN might serve a gzip-compressed response to a client that does not support compression.

Vary: Accept-Encoding

Cache separately for clients that accept gzip/brotli and those that do not.

Vary: Accept-Language

Cache separate copies for each language when serving localized content.

Be conservative with Vary. Every distinct header value multiplies the number of cached copies. Vary: Cookie or Vary: Authorization effectively disables CDN caching because every user has a different cookie or token.

Browser cache vs CDN cache

Both use the same Cache-Control header but behave differently:

Browser cache

  • Private to the individual user
  • Respects both public and private directives
  • Controlled by the end user (they can clear it)
  • Saves bandwidth for repeat visits

CDN cache

  • Shared across all users at an edge node
  • Only caches responses marked public
  • Can be purged programmatically on deploy
  • Saves server load and reduces latency globally

Practical recipes

Static assets with content hash

Cache-Control: public, max-age=31536000, immutable

Files like bundle.abc123.js never change at that URL. Cache them for a year and mark them immutable to skip revalidation entirely.

HTML pages

Cache-Control: no-cache

no-cache means "cache it, but check with the server before using it." The browser still sends a conditional request, but will get a 304 if nothing changed, which is fast.

Sensitive API responses

Cache-Control: no-store

Responses containing personal data, tokens, or financial information should not be stored anywhere. Every request must go to the server.

Public API responses

Cache-Control: public, max-age=60, stale-while-revalidate=30

A short max-age keeps data reasonably fresh. stale-while-revalidate lets the CDN serve the old response instantly while fetching a new one in the background.

Common mistakes

  • Using no-cache when you mean no-store. no-cache still caches the response; it just requires revalidation before use. If you truly need zero caching (passwords, tokens), use no-store.
  • Not setting Cache-Control at all. When no Cache-Control header is present, browsers apply heuristic caching based on the Last-Modified date. Results vary by browser and are rarely what you want.
  • Caching HTML for a long time. If your HTML references assets by filename (not content hash), a long-cached HTML page will keep pointing to the old assets even after a deploy.
  • Setting immutable without a content hash in the URL. immutable tells browsers the URL will never change. If you update the file at the same URL, cached users will never see the update.
  • Forgetting the Vary header for compressed responses. If your server sends gzip or brotli, add Vary: Accept-Encoding so CDNs store separate copies for compressed and uncompressed clients.

Frequently asked questions

What is the difference between no-cache and no-store?

no-cache means: store the response in cache but always revalidate with the server (via ETag or Last-Modified) before using it. If the server replies 304 Not Modified, the cached copy is served. no-store means: never write the response to cache at all. Every request goes all the way to the origin server. Use no-cache for pages that change often but are safe to cache. Use no-store for genuinely sensitive data.

How does the browser decide when to revalidate?

With max-age: the browser compares the current time to the response date plus max-age. If that window has passed, the response is "stale" and the browser sends a conditional request. With no-cache: the browser revalidates on every request regardless of age. With immutable: the browser skips revalidation entirely until max-age expires.

What does 304 Not Modified mean?

It means the cached copy is still current. The server compared the ETag or Last-Modified value from the request (If-None-Match or If-Modified-Since header) to the current state of the resource and found no change. The response body is empty, saving bandwidth. The browser uses the body it already has in cache.

Can I cache POST responses?

Technically yes, with explicit Cache-Control headers, but it is almost never correct. POST requests are not idempotent: submitting a form twice has different side effects than fetching a page twice. Caching POST responses can cause users to see stale mutation results. Treat POST responses as no-store by default.

How do I bust the cache for a new deploy?

The cleanest approach is content hashing: include a hash of the file contents in the URL (bundle.abc123.js). When the file changes, the hash changes, so the URL changes, and the old cache entry is never used again. For HTML and API responses that cannot be content-hashed, use no-cache so the browser always revalidates, and rely on ETags for efficiency.

Need to look up a status code?

Find any HTTP status code and its meaning instantly, including 304 and all caching-related codes.

Open HTTP Status Codes