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-revalidateETagis a strong validator: two responses with the same ETag are byte-identical. A weak validator,W/"9f2b1c", promises only equivalent meaning.Last-Modifiedis a weaker signal because it has one-second resolution; prefer an ETag derived from a version or content hash.- A
304must not carry a body, and it should repeat the caching headers so the client can update its freshness lifetime. - For writes,
If-Matchgives 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 retryCache-Control directives that matter
| Directive | Effect | Use it for |
|---|---|---|
no-store | Never write to any cache | Responses with personal data |
no-cache | Store, but revalidate before every use | Frequently changing private data |
private | Only the browser may cache | Anything user specific |
public | Shared caches may store it | Static reference data |
max-age=300 | Fresh for five minutes | Slow-changing collections |
s-maxage=600 | Overrides max-age for shared caches | CDN tuning |
stale-while-revalidate=60 | Serve stale for a minute while refreshing | Smoothing traffic spikes |
immutable | Never revalidate during its lifetime | Content-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.
Related
Resource modelling and methods Idempotency, retries and rate limiting
Last refreshed 2026-09-18.