StackKit

HTTP Caching Explained: Cache-Control, ETag and 304 Responses

By · · 4 min read

HTTP caching lets a browser or CDN reuse a response instead of fetching it again. Done well, it makes pages faster and cuts server load. Done badly, users see stale content that cannot be fixed without a hard refresh. Most of the behaviour is controlled by a few response headers, and the rules are simpler than they first appear.

Two ideas: freshness and validation

Every caching decision comes down to two questions:

  1. Freshness: can the stored copy be used without asking the server? That depends on how long the response is declared fresh.
  2. Validation: if the copy is stale, can the client ask the server "has this changed?" and get a cheap "no" instead of the whole body again?

Cache-Control answers the first question. ETag and Last-Modified support the second.

Cache-Control directives you will actually use

Directive Meaning
max-age=N The response is fresh for N seconds.
no-cache May be stored, but must be revalidated with the server before reuse.
no-store Do not store the response at all (sensitive data).
public Any cache, including shared CDNs, may store it.
private Only the user's own browser may store it, not shared caches.
immutable The content will never change while fresh, so skip revalidation.
s-maxage=N Like max-age, but for shared caches (CDNs) only.

A frequent misunderstanding: no-cache does not mean "do not cache". It means "cache it, but check before using it". If you want nothing stored, use no-store.

Validation with ETag and 304

An ETag is an identifier for a specific version of a response, often a hash of its content. The client stores it and sends it back on the next request in an If-None-Match header. If the version has not changed, the server replies 304 Not Modified with no body.

The following small Node.js server shows the full exchange:

const http = require("http");
const crypto = require("crypto");

const body = JSON.stringify({ hello: "world" });
const etag = '"' + crypto.createHash("sha1").update(body).digest("hex").slice(0, 12) + '"';

http.createServer((req, res) => {
  if (req.url === "/api") {
    res.setHeader("ETag", etag);
    res.setHeader("Cache-Control", "no-cache");
    if (req.headers["if-none-match"] === etag) {
      res.statusCode = 304;
      return res.end();
    }
    res.setHeader("Content-Type", "application/json");
    return res.end(body);
  }
  res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
  res.end("asset");
}).listen(3199);

Requesting /api the first time returns the body and an ETag:

HTTP/1.1 200 OK
ETag: "2248ee2fa0aa"
Cache-Control: no-cache

Sending that tag back with curl -H 'If-None-Match: "2248ee2fa0aa"' localhost:3199/api produces:

HTTP/1.1 304 Not Modified
ETag: "2248ee2fa0aa"

The browser still makes a request, but the response has no body, so it is small and fast. Any other path returns the long-lived header:

HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable

A practical strategy

Fingerprinted static assets: cache for a year

Files like app.4f2a1c.js have a hash in the name that changes whenever the content changes. Since the URL is unique to the content, they can be cached for the maximum useful time:

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

Build tools (including Next.js, Vite and webpack) produce fingerprinted file names for you.

HTML pages: revalidate

The HTML document references those assets, so it must stay up to date. Use no-cache (with an ETag) or a short max-age. Users get the new HTML promptly, and it points to the new asset URLs.

API responses

  • Public, slowly changing data: a short max-age or s-maxage, optionally with stale-while-revalidate.
  • Per-user data: private (or no-store for sensitive data), so a CDN never serves one user's response to another.

Use Vary when responses differ by header

If a response differs by a request header such as Accept-Encoding or Accept-Language, include a Vary header so caches keep separate copies.

Checking what you serve

In the browser, open DevTools → Network and look at the Size column: "(memory cache)" and "(disk cache)" mean no request was made, and a 304 means revalidation succeeded. From a terminal:

curl -sI https://example.com/app.js | grep -iE "cache-control|etag|age"

The Age header, set by shared caches, tells you how many seconds a response has been sitting there.

Common mistakes

  • Using no-cache when you meant no-store, or the reverse.
  • Caching HTML for a long time, so users keep seeing old asset references.
  • Long max-age on files whose names do not change (style.css), which makes updates invisible.
  • Marking personalised responses public.
  • Forgetting that a CDN may cache differently from the browser; set s-maxage explicitly when you need to.

Summary

Cache fingerprinted assets for a year, make HTML and APIs revalidate cheaply with ETag, and mark private data private or no-store. Once you separate "can I reuse this without asking?" from "can I check cheaply?", the headers stop being mysterious.