Concept
The beginner framing: if a client already has a copy of a resource and it hasn't changed since they fetched it, re-sending the entire response body again is wasted bandwidth, HTTP has a built-in protocol for a client to ask "has this changed since I last checked?" and get back a tiny "no, you already have the latest" instead of the full payload.
Cache-Control, telling caches how long a response is good for
Cache-Control: public, max-age=60, s-maxage=60Confirmed via a real request against a live API (GitHub's), this is an actual response header from a production API, not a hypothetical. public means any cache (browser, CDN, shared proxy) may store this response, not just the requesting client. max-age=60 tells a private/browser cache the response is fresh for 60 seconds. s-maxage=60 is the shared-cache-specific override (CDNs, reverse proxies), letting an API set a different freshness window for shared infrastructure caches versus individual browsers, which matters because a shared cache serves many users and has different cost/staleness tradeoffs than one user's browser cache.
Conditional requests, ETag + If-None-Match, confirmed end-to-end
GET /users/octocat
→ 200 OK
ETag: W/"b52bcc9d...42a"
(full response body)
GET /users/octocat
If-None-Match: W/"b52bcc9d...42a"
→ 304 Not Modified
(NO body at all, just headers, confirmed via a real curl round-trip)Confirmed this session, end-to-end, against a real live API: the first request returns a full body along with an ETag, an opaque identifier representing this exact version of the resource. Sending that SAME ETag back on a follow-up request via If-None-Match gets a genuine 304 Not Modified response, no body transferred at all, just a status code confirming the client's cached copy is still current. The W/ prefix marks this as a weak ETag (semantically equivalent, not necessarily byte-identical, acceptable for most caching purposes); a strong ETag (no W/ prefix) guarantees byte-for-byte identity, required for things like resuming a partial download correctly.
Last-Modified + If-Modified-Since, the older, coarser sibling
Last-Modified: Mon, 22 Jun 2026 11:32:20 GMT
If-Modified-Since: Mon, 22 Jun 2026 11:32:20 GMT
→ 304 Not Modified (if unchanged since that timestamp)The same conditional-request pattern, but keyed on a timestamp rather than an opaque version identifier. It's coarser (second-level granularity, a resource that changes twice within the same second is indistinguishable) and easier to get wrong (clock skew, or a resource that's regenerated identically but gets a new "modified" timestamp anyway), ETag/If-None-Match is generally preferred when a server can compute a genuine content hash or version identifier; Last-Modified remains useful as a fallback or for resources where a timestamp is naturally already tracked.
Try It
Predict the outcome before checking the solution.
Client's cached copy: ETag: "v3-abc"
GET /products/42
If-None-Match: "v3-abc"
// Server has ACTUALLY updated the product since, its current ETag is now "v4-xyz"What does the server return, and why is returning the correct thing here more subtle than it first appears?
Solution
The server must return a full 200 OK with the new body and the new ETag ("v4-xyz"), NOT a 304. This is the entire point of the mechanism: the server compares the client's SUBMITTED If-None-Match value against its OWN current ETag; since they don't match ("v3-abc" ≠ "v4-xyz"), the resource HAS changed since the client's last fetch, and a 304 would be actively wrong, it would tell the client to keep using stale data. The subtlety is that this comparison has to happen correctly server-side on every request, not just "return 304 if an If-None-Match header is present at all", a common, serious implementation bug is checking for the HEADER's presence rather than actually comparing its VALUE against the current resource state, which would silently serve stale data to every client forever after their first fetch.
Implement It Yourself
Build a minimal ETag-based conditional-request handler, the actual server-side logic behind the curl round-trip confirmed above:
const crypto = require("crypto");
function computeEtag(body) {
const hash = crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex").slice(0, 16);
return `"${hash}"`;
}
function handleGet(resource, ifNoneMatch) {
const currentEtag = computeEtag(resource);
if (ifNoneMatch === currentEtag) {
return { status:
The mechanism: the ETag is a deterministic function of the resource's current content, computing it fresh on every request and comparing against what the client sent is what makes the check correct (the bug flagged in Try It would instead just check if (ifNoneMatch) return 304, which is wrong the instant the resource actually changes).
Under the Hood
Whether a list endpoint's response is safe to cache at all is directly complicated by the pagination strategy covered in Pagination, Filtering & Sorting Design, an offset-paginated page's contents can shift under concurrent writes, undermining a cache's basic assumption that a given URL consistently maps to the same content. And caching interacts directly with the resource-safety semantics from REST Design & Best Practices, only safe methods (GET) are meaningfully cacheable in the first place; caching a mutating response doesn't make sense under HTTP's model at all.
Common Mistakes
1. Checking only whether If-None-Match is present, not comparing its value
if (req.headers["if-none-match"]) return res.status(304).end(); // ❌ ALWAYS 304s, ignoring actual changesThis is exactly the bug flagged in Try It, it silently serves stale "not modified" responses forever after the client's first request, regardless of whether the resource actually changed. The comparison must be against the CURRENT computed ETag, not merely header presence.
2. Using a strong ETag for content that's semantically-but-not-byte-identical
ETag: "abc123" // ❌ strong ETag on JSON serialized with inconsistent key orderingIf two requests produce semantically identical data but a different byte-for-byte serialization (e.g. non-deterministic key ordering, or a timestamp field that regenerates even when nothing meaningful changed), a strong ETag will mismatch on every request, defeating caching entirely, a weak ETag (W/"...") or a hash computed only over the meaningful fields avoids this.
3. Setting Cache-Control: public on a response containing user-specific data
Cache-Control: public, max-age=300 // ❌ on a response containing THIS user's private order historypublic permits ANY shared cache (a CDN, a corporate proxy) to store and serve this response to OTHER users, a serious data-leak risk for anything containing per-user or otherwise sensitive content. User-specific responses need private (client-only caching) or no-store entirely.
Best Practices
- Use
ETag/If-None-Matchfor content where a genuine version/hash can be computed, it's more precise than timestamp-based validation and avoids clock-skew/granularity issues. - Never mark user-specific or sensitive responses
Cache-Control: public, useprivateorno-storeto prevent shared caches from leaking one user's data to another. - Recompute the ETag from actual current content on every request, never shortcut the comparison to just checking header presence.
- Use
s-maxagewhen a CDN/shared-cache freshness window should differ from the browser's own cache window, these are legitimately different tradeoffs (a CDN serves many users; a browser cache serves one). - Prefer weak ETags () unless byte-for-byte identity genuinely matters (e.g. range requests/resumable downloads), since they tolerate harmless serialization differences that would otherwise defeat caching.
Performance Tips
- A
304 Not Modifiedresponse transfers essentially zero payload, for a client that already has current data (a very common case for polling/refresh patterns), this is a dramatic bandwidth and latency win over re-sending the full body every time. - Computing an ETag has a real cost proportional to the resource size (hashing the content), for very large or expensive-to-serialize resources, this cost should be weighed against the caching benefit, and may be worth caching the ETag itself alongside the data rather than recomputing it from scratch on every single request.
