Two Clients, One Record

ETag and If-Match turn a lost update into a 412 the client can act on, instead of a silent overwrite.

SyntaxIf-Match: "42" -> 200 OK | 412 Precondition Failed

Two support agents open ticket 77. One edits the subject, the other edits the body. Both PATCH. The second write lands on a copy read before the first, so the first agent's change is gone — and both requests returned 200. That is a lost update, and its defining property is that nothing anywhere reports an error.

ETag and If-Match

Return an ETag on GET that identifies the version the client now holds. Require the write to echo it as If-Match. If it no longer matches, answer 412 Precondition Failed: per RFC 9110, "one or more conditions given in the request header fields evaluated to false when tested on the server". If-Match uses strong comparison, so a weak entity tag (W/"...") never satisfies it. If the client omits the header entirely, 428 Precondition Required (RFC 6585) tells it what to send; accepting the write unconditionally reopens the hole you just closed.

412 against 409

412 means "you were working from a stale copy" — purely mechanical, and a client can handle it in a loop: re-read, re-apply, retry. 409 means the conflict is semantic and a retry of the same request is still wrong, so a human or a different decision is needed. Returning 409 for staleness denies the client the one response it could have automated.

A version field in the body

Echoing a version integer and doing UPDATE ... WHERE version = $n is the same idea with worse ergonomics: caches, proxies and HTTP libraries understand If-Match and understand nothing about your field. You usually need the column anyway for the compare-and-set — so expose it as the ETag.

Example

Example · javascript
// Express's default ETag is weak; we emit a strong one from the row version,
// so the HTTP precondition and the SQL compare-and-set share one source of truth.
app.set('etag', false);

app.get('/tickets/:id', async (req, res) => {
  const t = await db.oneOrNone(
    'SELECT id, subject, body, version FROM tickets WHERE id = $1', [req.params.id]);
  if (!t) return res.status(404).end();
  res.set('ETag', `"${t.version}"`);   // res.set === res.setHeader in Express
  res.json(t);
});

app.patch('/tickets/:id', async (req, res) => {
  const ifMatch = req.get('If-Match');
  if (!ifMatch) {
    // 428 = "send a precondition", distinct from 412 = "yours failed".
    return res.status(428).json(problem('precondition-required', 428));
  }
  if (ifMatch.startsWith('W/')) {
    // A weak tag can never satisfy If-Match, so say so rather than guessing.
    return res.status(400).json(problem('weak-etag-not-allowed', 400));
  }
  const expected = Number(ifMatch.replaceAll('"', ''));

  // Compare-and-set in one statement. rowCount is the answer.
  const { rowCount, rows } = await db.query(
    `UPDATE tickets
        SET subject = COALESCE($1, subject),
            body    = COALESCE($2, body),
            version = version + 1
      WHERE id = $3 AND version = $4
      RETURNING id, subject, body, version`,
    [req.body.subject ?? null, req.body.body ?? null, req.params.id, expected]);

  if (rowCount === 0) {
    // Distinguish "gone" from "stale" — the client reacts differently.
    const exists = await db.oneOrNone('SELECT 1 FROM tickets WHERE id = $1', [req.params.id]);
    return exists
      ? res.status(412).json(problem('stale-etag', 412))
      : res.status(404).end();
  }
  res.set('ETag', `"${rows[0].version}"`).json(rows[0]);
});

When to use it

  • A PATCH endpoint where two admin users silently overwrite each other's edits
  • Adding optimistic concurrency to an existing resource without a schema migration
  • Deciding whether a stale write should return 409 or 412 so the SDK can retry it automatically
  • Debugging why If-Match never matches behind a proxy that rewrites ETags

More examples

Why the check has to be in the UPDATE

The two-statement form is the bug behind most 'we added optimistic locking and it still overwrites' reports: between the SELECT and the UPDATE there is no lock at all. The hash variant is useful on a legacy table you are not allowed to migrate, at the cost of recomputing the hash on read.

Example · sql
-- BROKEN: two sessions both read version 7 and both pass the check.
SELECT version FROM tickets WHERE id = 77;          -- 7 in both sessions
-- ... application compares 7 = 7, then:
UPDATE tickets SET subject = $1, version = 8 WHERE id = 77;  -- last one wins

-- CORRECT: the predicate is part of the write, so one session loses.
UPDATE tickets
   SET subject = $1, version = version + 1
 WHERE id = 77 AND version = 7;
-- UPDATE 1  -> we won, 200 with the new ETag
-- UPDATE 0  -> someone else moved it, 412

-- The same shape with a content hash instead of a counter, when you have
-- no version column to add:
UPDATE tickets SET subject = $1
 WHERE id = 77 AND md5(subject || body) = $2;

The client loop a 412 makes possible

This is the payoff for returning 412 rather than 409 — the retry is mechanical and bounded. Note that `newSubject` is a function of the current value: a loop that re-sends a value computed before the first read will keep clobbering the other writer even though every request now carries a precondition.

Example · javascript
async function renameTicket(id, newSubject, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    const read = await fetch(`/tickets/${id}`);
    const etag = read.headers.get('ETag');
    const ticket = await read.json();

    const write = await fetch(`/tickets/${id}`, {
      method: 'PATCH',
      headers: { 'If-Match': etag, 'Content-Type': 'application/json' },
      // Merge against the copy we just read, not against stale local state.
      body: JSON.stringify({ subject: newSubject(ticket.subject) }),
    });

    if (write.ok) return write.json();
    if (write.status !== 412) throw new Error(`patch failed: ${write.status}`);
    // 412: someone else wrote in between. Re-read and re-apply.
  }
  throw new Error('too much contention on ticket ' + id);
}

When the version lives in the body instead

Reach for the body field only when you cannot control the response headers — behind a gateway that strips or rewrites ETag, or in a GraphQL or JSON-RPC surface where there is no per-resource ETag to begin with. If you do, document that the field is a precondition and not a writable attribute, or clients will send it back as data.

Example · http
GET /tickets/77
200 OK
{"id": 77, "subject": "Printer offline", "version": 7}

# Same mechanism, worse ergonomics: the precondition is now payload,
# so no cache, proxy or generic HTTP client can see or enforce it,
# and PUT/PATCH bodies must carry a field that is not really data.
PATCH /tickets/77
{"subject": "Printer offline in 3F", "version": 7}
409 Conflict
{"type": ".../version-conflict", "current_version": 9}

# The header form, for comparison. Same database predicate underneath.
PATCH /tickets/77
If-Match: "7"
{"subject": "Printer offline in 3F"}
412 Precondition Failed

Discussion

  • Be the first to comment on this lesson.