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.

Conditional read and conditional write The client first fetches a document and receives ETag v42. A later conditional read with If-None-Match v42 returns 304 with no body. Another client updates the document to v43. A conditional write from the first client with If-Match v42 returns 412 Precondition Failed. client A API GET /documents/d1 200 · ETag: "v42" · body GET · If-None-Match: "v42" 304 · no body · ETag: "v42" client B updates → ETag becomes "v43" PUT · If-Match: "v42" 412 Precondition Failed · current ETag "v43"

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

Strong versus weak validators Strong validators support cache revalidation, range requests and If-Match write preconditions. Weak validators support cache revalidation only, and are cheaper to compute because semantically equivalent representations may share a tag. capability strong "v42" weak W/"v42" cache revalidation (If-None-Match) write precondition (If-Match) range requests (If-Range) byte-for-byte equality implied ✗ semantic only cheap to compute from a timestamp

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:

Validator sources and how each one fails A row version changes exactly when the row changes. A content hash is correct but requires rendering first. A timestamp is cheap but coarse. A per-request value defeats caching entirely. the tag must change if and only if the representation does row version column incremented by the write compare before rendering anything hash of the representation exact, strong validator must render before deciding truncated updated_at cheap, weak validator coarse: two writes in one secondcollide anything per-request never repeats every conditional request misses a validator that includes a timestamp or a per-caller value is a cache with the benefits removed

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.