ETag and If-None-Match Conditional Requests
This page belongs to Statelessness & Caching Strategies in the API Design Fundamentals & Architecture reference. Conditional requests are the mechanism that lets a stateless API avoid re-sending unchanged data and avoid overwriting concurrent edits — two problems solved by the same header family and routinely implemented only halfway.
The decision trigger
| Symptom | Cause |
|---|---|
| Polling clients re-download identical payloads every minute | no ETag on responses |
| Two users edit the same record; the later write silently wins | no If-Match precondition on writes |
A 304 breaks a client’s cache |
required headers omitted from the 304 |
The ETag changes on every request |
validator derived from a timestamp or a rendered value that varies |
| A range request is refused | weak validator used where strong is required |
| Clients ignore the header entirely | ETag returned but never documented |
Spec snippet: the full conditional contract
# openapi.yaml (OpenAPI 3.1.0)
paths:
/documents/{documentId}:
get:
operationId: getDocument
parameters:
- name: If-None-Match
in: header
required: false
schema: { type: string }
example: '"v42"'
responses:
"200":
description: The document.
headers:
ETag:
description: Strong validator for this representation.
schema: { type: string }
example: '"v42"'
Cache-Control:
schema: { type: string }
example: "private, max-age=0, must-revalidate"
content:
application/json:
schema: { $ref: "#/components/schemas/Document" }
"304":
description: Not modified — the client's validator still matches.
headers:
ETag: { schema: { type: string } }
Cache-Control: { schema: { type: string } }
put:
operationId: replaceDocument
parameters:
- name: If-Match
in: header
required: true # unconditional writes are refused
schema: { type: string }
example: '"v42"'
responses:
"200": { description: Replaced. }
"412":
description: The document changed since the supplied validator.
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
"428":
description: If-Match is required for this operation.
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
Marking If-Match as required: true on the write is the decision that turns optimistic concurrency from an option into a guarantee. It also makes the requirement visible in generated clients, so callers discover it at compile time rather than through a 428 in production.
Step-by-step
Step 1 — Generate a validator that changes exactly when the representation does
// etag.ts — two viable strategies, one recommended
import { createHash } from "node:crypto";
/** Preferred: a row version column, incremented by the database on write. */
export function versionTag(row: { version: number }): string {
return `"v${row.version}"`; // strong validator
}
/** Alternative: hash the canonical representation. Correct, but you must render first. */
export function contentTag(representation: unknown): string {
const canonical = JSON.stringify(representation, Object.keys(representation as object).sort());
return `"${createHash("sha256").update(canonical).digest("base64url").slice(0, 27)}"`;
}
/** Weak validator: use only where semantic equivalence is enough. */
export function weakTag(row: { updated_at: Date }): string {
return `W/"${Math.floor(row.updated_at.getTime() / 1000)}"`;
}
The version-column approach wins on cost: you can compare the client’s validator against the stored version before loading relations, rendering JSON or serialising anything. The hash approach requires producing the full representation to discover you did not need to send it.
Avoid deriving a validator from a wall-clock timestamp with sub-second resolution, from a value that includes per-caller data, or from anything that varies between replicas — all three produce a tag that changes without the representation changing, which turns every conditional request into a full response.
Step 2 — Serve 304 correctly
// get-document.ts — conditional read
import type { Request, Response } from "express";
export async function getDocument(req: Request, res: Response) {
const meta = await documents.metadata(req.params.documentId); // id + version only
if (!meta) return sendNotFound(res);
const etag = `"v${meta.version}"`;
// Cache headers belong on BOTH the 200 and the 304.
res.setHeader("ETag", etag);
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
res.setHeader("Vary", "Accept, Authorization");
const inm = req.header("If-None-Match");
if (inm && matchesAny(inm, etag)) {
return res.status(304).end(); // no body, headers already set
}
const document = await documents.render(req.params.documentId); // now do the work
res.status(200).json(document);
}
/** RFC 9110 §13.1.2: If-None-Match may be a list, or `*`. Weak comparison applies. */
function matchesAny(headerValue: string, etag: string): boolean {
if (headerValue.trim() === "*") return true;
const normalise = (t: string) => t.trim().replace(/^W\//, "");
return headerValue.split(",").some(candidate => normalise(candidate) === normalise(etag));
}
Two details that are commonly wrong: If-None-Match may carry a list of validators and the literal *, and comparison for If-None-Match uses the weak algorithm, meaning W/"v42" and "v42" match each other.
Step 3 — Use If-Match to prevent lost updates
# update_document.py — optimistic concurrency with a precondition
from fastapi import APIRouter, Header, HTTPException, Response
router = APIRouter()
@router.put("/documents/{document_id}")
async def replace_document(document_id: str, body: DocumentIn, response: Response,
if_match: str | None = Header(default=None)):
if if_match is None:
# RFC 6585 §3 — the resource requires a precondition.
raise HTTPException(
status_code=428,
detail={
"type": "urn:api:errors:v1:precondition-required",
"title": "If-Match is required",
"status": 428,
"detail": "Fetch the document, then retry with its ETag in If-Match.",
},
)
expected = if_match.strip().strip('"').removeprefix("v")
# Compare-and-set in ONE statement: no read-then-write race.
updated = await db.fetchrow(
"""UPDATE documents
SET content = $1, version = version + 1
WHERE id = $2 AND version = $3
RETURNING version""",
body.content, document_id, int(expected),
)
if updated is None:
current = await db.fetchval("SELECT version FROM documents WHERE id = $1", document_id)
if current is None:
raise HTTPException(status_code=404)
raise HTTPException(
status_code=412,
headers={"ETag": f'"v{current}"'},
detail={
"type": "urn:api:errors:v1:precondition-failed",
"title": "Document changed since the supplied ETag",
"status": 412,
"current_etag": f'"v{current}"',
},
)
response.headers["ETag"] = f'"v{updated["version"]}"'
return {"ok": True}
The compare-and-set in the UPDATE statement is the part that actually provides the guarantee. Checking the version in application code and then writing leaves a window in which another writer commits, which is precisely the lost update the precondition exists to prevent.
Step 4 — Choose strong or weak deliberately
If you serve a compressed representation whose bytes differ between encodings, a strong validator must differ per encoding — which is why Vary: Accept-Encoding and per-encoding tags travel together. Where that is impractical, a weak validator is the honest choice, and you then cannot use If-Match for concurrency on that resource.
Standard compliance
| Element | Standard | Clause | Note |
|---|---|---|---|
ETag syntax |
RFC 9110 | §8.8.3 | Quoted string, optional W/ prefix |
If-None-Match |
RFC 9110 | §13.1.2 | Weak comparison; list or * |
If-Match |
RFC 9110 | §13.1.1 | Strong comparison |
304 Not Modified |
RFC 9110 | §15.4.5 | No body; must carry cache-relevant headers |
412 Precondition Failed |
RFC 9110 | §15.5.13 | Precondition evaluated false |
428 Precondition Required |
RFC 6585 | §3 | Server demands a conditional request |
Vary |
RFC 9110 | §12.5.5 | Which request headers select the representation |
The 304 clause is worth reading closely: the response must include the headers a cache needs to update its stored entry — ETag, Cache-Control, Vary, Date — and must not include those that describe a body it is not sending.
Validator generation is where most implementations go wrong, and the failure is always the same shape — a tag that changes when the representation has not:
Idempotency and safety implications
A conditional PUT with If-Match is idempotent in the strict sense — repeating it against an unchanged resource either succeeds identically or fails with 412 — which makes it safe to retry automatically after a network error, unlike an unconditional POST. This is the cleanest form of write safety available in HTTP, and it needs no additional key infrastructure. Where the operation is a creation rather than a replacement, If-None-Match: * expresses “create only if it does not already exist”, giving the same protection for the create case. Beyond those two, non-idempotent operations need the machinery in Idempotency Key Implementation instead.
SDK / codegen downstream effect
// Generated client: the header is a first-class parameter
- await client.documents.replaceDocument({ documentId: id, documentIn: body });
+ const current = await client.documents.getDocumentRaw({ documentId: id });
+ await client.documents.replaceDocument({
+ documentId: id,
+ ifMatch: current.raw.headers.get("etag")!, // required: true in the spec
+ documentIn: body,
+ });
Because If-Match is declared required: true, the generated method will not compile without it, so every caller is forced into the read-then-conditional-write pattern. A caching layer in the client wrapper can then reuse the stored ETag automatically, which is how conditional requests end up costing callers nothing.
Common mistakes
| Mistake | Correct approach |
|---|---|
ETag derived from a timestamp that changes per request |
Derive from a row version or a content hash |
304 without ETag and Cache-Control |
Include cache-relevant headers on the 304 |
Comparing If-None-Match with strong comparison |
Use weak comparison; handle lists and * |
| Reading the version, then writing in a separate statement | Compare-and-set in one statement |
Weak validator used with If-Match |
If-Match requires strong comparison |
| Rendering the full representation before checking the validator | Check the version first, then render |
Returning 409 where the precondition failed |
412 is the specified code |
FAQ
What is the difference between a strong and a weak ETag?
A strong validator promises that two representations with the same tag are byte-for-byte identical, which is what makes it usable for range requests and for If-Match write preconditions. A weak validator, written with the W/ prefix, promises only that the two are semantically equivalent — the content means the same thing, but the bytes may differ, perhaps because of a re-serialised member order or a changed compression. Weak validators are cheaper to produce, since a truncated updated_at timestamp suffices, and they are perfectly adequate for cache revalidation. What they cannot do is protect a write, because “semantically equivalent” is not a strong enough guarantee to base an overwrite on.
Should an ETag be a hash of the response body?
A hash is correct and easy to reason about, but it has a cost that matters at scale: you must build the entire representation — load relations, apply projections, serialise JSON — before you can compute the tag and discover the client already has it. A monotonically increasing version column inverts that: read the version alongside the record’s primary key, compare it to If-None-Match, and return 304 having touched almost nothing. Use a hash when there is no natural version, or when the representation depends on data from several sources whose versions you cannot combine — and cache the computed hash alongside the record if you do.
Does a 304 response need to repeat the response headers?
It needs to carry the headers a cache requires to update its stored entry: ETag, Cache-Control, Vary, and Date. It must not include a body, and headers describing a body it is not sending — Content-Length, Content-Type — are omitted. The practical failure this prevents is a cache that stores the original 200, receives a 304 without Cache-Control, and either discards its freshness information or treats the entry as stale immediately, defeating the revalidation you implemented.
Related
- Statelessness & Caching Strategies — up-link: the section covering cache control and stateless design
- API Design Fundamentals & Architecture — up-link: the parent reference for HTTP semantics and resource design
- 409 Conflict vs 412 Precondition Failed — choosing between the two conflict codes
- Collection vs Singleton Resource Naming — which resource shapes benefit most from validators
- Idempotency Key Implementation — write safety where preconditions do not apply