Caching with ETag and Cache-Control

Validators and directives let a client skip a body it already has, which is the cheapest performance work available on any API.

Conditional requests

GET /articles/42 HTTP/1.1

HTTP/1.1 200 OK
ETag: "9f2b1c"
Cache-Control: private, max-age=60, must-revalidate

# The client asks again later
GET /articles/42 HTTP/1.1
If-None-Match: "9f2b1c"

HTTP/1.1 304 Not Modified
ETag: "9f2b1c"
Cache-Control: private, max-age=60, must-revalidate
  • ETag is a strong validator: two responses with the same ETag are byte-identical. A weak validator, W/"9f2b1c", promises only equivalent meaning.
  • Last-Modified is a weaker signal because it has one-second resolution; prefer an ETag derived from a version or content hash.
  • A 304 must not carry a body, and it should repeat the caching headers so the client can update its freshness lifetime.
  • For writes, If-Match gives you optimistic concurrency: the update only applies if the client's ETag is still current, otherwise 412 Precondition Failed.
  • If-Match: * means "only if the resource exists" and is a clean way to make a create-or-replace safe.
PUT /articles/42 HTTP/1.1
If-Match: "9f2b1c"
Content-Type: application/json

{"title":"Caching, revised"}

HTTP/1.1 412 Precondition Failed
# someone else updated the article first; reload and retry

Cache-Control directives that matter

DirectiveEffectUse it for
no-storeNever write to any cacheResponses with personal data
no-cacheStore, but revalidate before every useFrequently changing private data
privateOnly the browser may cacheAnything user specific
publicShared caches may store itStatic reference data
max-age=300Fresh for five minutesSlow-changing collections
s-maxage=600Overrides max-age for shared cachesCDN tuning
stale-while-revalidate=60Serve stale for a minute while refreshingSmoothing traffic spikes
immutableNever revalidate during its lifetimeContent-hashed asset URLs
// Deriving a stable ETag from a version field rather than the full body
import crypto from "node:crypto";

function etagFor(article) {
  const v = article.updatedAt + ":" + article.version;
  return '"' + crypto.createHash("sha1").update(v).digest("hex").slice(0, 16) + '"';
}

export function getArticle(req, res) {
  const article = load(req.params.id);
  if (!article) return res.status(404).json({ error: "not_found" });

  const tag = etagFor(article);
  if (req.headers["if-none-match"] === tag) {
    res.set("ETag", tag);
    res.set("Cache-Control", "private, max-age=60");
    return res.status(304).end();
  }
  res.set("ETag", tag);
  res.set("Cache-Control", "private, max-age=60");
  res.set("Vary", "Accept, Authorization");
  return res.json(article);
}
⚠️
Never cache an authenticated response without private and a Vary header that includes Authorization. A shared cache that ignores the credential is how one customer's data ends up in another customer's response.

FAQ

Should an ETag be a hash of the body?
A version counter or an updated-at timestamp is cheaper and avoids recomputing a hash on every request. Hash the body only when you have no better change indicator.
Why does my 304 come back with 200 in the browser?
Because the browser had no cached copy and sent no If-None-Match, so the server correctly returned the full representation. Conditional requests only produce 304 for clients that already hold the resource.

Resource modelling and methods Idempotency, retries and rate limiting

Last refreshed 2026-09-18.