Skip to main content
Web

If-Match and 412: stopping lost updates with optimistic concurrency over HTTP

Two clients PUT the same resource and the second silently erases the first. How ETag, If-Match, 412 and 428 give you optimistic locking with no version column in the URL, with real traces and a Node handler.

Thien
By Thien
Updated November 7, 2026 · 5 min read
two people at a desk with laptops and notebooks working on the same document

To stop two clients overwriting each other, make the server hand out an ETag on every GET, make the client echo it back as If-Match on PUT or PATCH, and have the server reply 412 Precondition Failed when the stored version no longer matches. Reject writes that carry no If-Match at all with 428 Precondition Required. That is optimistic concurrency control, and it is built into HTTP: no lock, no version field in your URL scheme.

This is the write-side sibling of ETag and 304. Same header family, opposite job: If-None-Match saves bandwidth, If-Match saves data.

The bug: last write wins, silently

Alice and Bob both open the same profile. Both GET version 1. Alice changes the display name, Bob changes the email. Each sends a PUT with the full document.

StepWhoRequestStored state afterwards
1AliceGET /users/7 → ETag: "v1"name=Al, email=old@x.io
2BobGET /users/7 → ETag: "v1"name=Al, email=old@x.io
3AlicePUT name=Alicename=Alice, email=old@x.io
4BobPUT email=new@x.io (name still "Al")name=Al, email=new@x.io

Both requests returned 200. Alice's rename is gone and nobody got an error. Retries, mobile clients on flaky networks and background syncs make this far more common than two humans racing.

Nothing in PUT itself prevents this. A PUT is "replace the resource with this", and the server is entitled to do exactly that. The protection has to be opted into with a precondition.

The fix: the same trace with If-Match

GET /users/7 HTTP/1.1

HTTP/1.1 200 OK
ETag: "v1"
Content-Type: application/json

{"name":"Al","email":"old@x.io"}

Alice writes first and succeeds, and the server bumps the version:

PUT /users/7 HTTP/1.1
If-Match: "v1"
Content-Type: application/json

{"name":"Alice","email":"old@x.io"}

HTTP/1.1 200 OK
ETag: "v2"

Bob's write carries the stale validator and bounces:

PUT /users/7 HTTP/1.1
If-Match: "v1"
Content-Type: application/json

{"name":"Al","email":"new@x.io"}

HTTP/1.1 412 Precondition Failed
ETag: "v2"
Content-Type: application/problem+json

{"type":"about:blank","title":"Resource changed","status":412}

Bob's client now knows its copy is stale. It re-fetches (or reads the ETag off the 412), merges, and retries with If-Match: "v2". Whether to merge automatically or show a conflict dialog is a product decision; HTTP only guarantees you find out.

A Node handler that does it right

The check and the write must be one atomic step. Compare in application code and then update, and you've rebuilt the race one layer down.

app.put("/users/:id", async (req, res) => {
  const ifMatch = req.get("If-Match");
  if (!ifMatch) {
    return res.status(428).json({ title: "If-Match header required" });
  }

  // version is an integer column; the ETag is just its quoted form
  const expected = Number(ifMatch.replace(/^"|"$/g, ""));
  const { rowCount, rows } = await db.query(
    `UPDATE users SET name = $1, email = $2, version = version + 1
     WHERE id = $3 AND version = $4
     RETURNING version`,
    [req.body.name, req.body.email, req.params.id, expected]
  );

  if (rowCount === 0) {
    // either the row is gone (404) or the version moved (412)
    const exists = await db.query("SELECT version FROM users WHERE id = $1", [req.params.id]);
    if (exists.rowCount === 0) return res.sendStatus(404);
    res.set("ETag", `"${exists.rows[0].version}"`);
    return res.sendStatus(412);
  }
  res.set("ETag", `"${rows[0].version}"`).json({ ok: true });
});

The WHERE ... AND version = $4 is the whole trick. The database does the compare-and-swap, so two concurrent requests can't both win. Same idea as UPDATE ... WHERE updated_at = ? in an ORM's "optimistic locking" feature; the HTTP layer just gives it a standard wire format.

Common mistakes

1. Using a weak ETag with If-Match

If-Match requires the strong comparison function (RFC 9110 §13.1.1). A W/"v1" validator never matches, so every write 412s. Weak ETags are fine for cache revalidation and wrong here. If a compression layer rewrites your ETags to weak, your writes break.

2. Making If-Match optional

If a client may omit the header, the safe clients are protected and the sloppy ones still clobber data, so the guarantee is worth little. Return 428 Precondition Required (RFC 6585) when it's missing. Yes, it breaks lazy clients; that is the point. Roll it out by logging first, enforcing later.

3. Hashing the response body as the ETag

A body hash changes when serialization changes (field order, a new nullable field), producing spurious 412s after a deploy. A version counter or updated_at column changes only when the data does.

4. Returning 409 or 400 instead

409 Conflict is legitimate for business-rule conflicts ("username taken"). For a failed precondition, 412 is the status clients and libraries recognize. Use If-Match and 412 for "your copy is stale", and 409 for everything else.

5. Forgetting If-Match on DELETE

Delete-after-update is a lost update too: Bob deletes a record that Alice just improved. DELETE accepts If-Match identically.

If-Match vs If-None-Match: *

Two preconditions people mix up:

HeaderValueMeaningTypical use
If-Match"v1"Proceed only if the current version is v1Update or delete an existing resource safely
If-None-Match*Proceed only if the resource does not existCreate-only PUT, no overwrite of an existing key

If-None-Match: * is how S3-style object stores implement "create if absent" without a read-then-write race.

FAQ

Does If-Match work with PATCH?

Yes. Preconditions are method-agnostic. It matters more for PATCH than PUT, because a JSON Merge Patch applied against a stale base can produce a document neither client ever saw.

What if my API has no ETag support yet?

Add a version integer to the row, expose it as ETag: "<version>" on GET and on every successful write, and add the WHERE version = ? clause. That's the entire migration. The ETag header is also a good place to answer "what changed" for cache revalidation later.

Is this the same as idempotency keys?

No. Idempotency keys make a retry of the same request safe. If-Match makes two different requests safe against each other. Payment-style endpoints often want both.

Can browsers' fetch send If-Match?

Yes. It is not a CORS-safelisted header, so cross-origin calls need it listed in Access-Control-Allow-Headers, and you should expose ETag through Access-Control-Expose-Headers or JavaScript can't read it. The CORS headers builder gets that pair right.

The short version

Optimistic concurrency is three small rules: GET returns a strong ETag, writes require If-Match (428 if absent), and the database compares and swaps in one statement (412 if it lost). That's enough to turn silent data loss into an error your client can handle.

Cover photo from Pexels.

References

Primary documentation and specifications checked when this article was last updated.

WebHTTPAPI

Related articles

All articles