Optimistic concurrency for HTTP APIs: ETags, If-Match, and conflict design

Prevent lost updates without pessimistic locks: versioned resources, conditional requests, 412 vs 409, and how optimistic concurrency interacts with caching, BFFs, and mobile retries.

作者: Matheus Palma约 8 分钟阅读
Software engineeringAPI designBackendHTTPReliabilityArchitecture

Two users edit the same invoice line item. Both click save. The second request silently overwrites the first, and finance discovers the discrepancy days later. You could serialize edits with a lock or a single-writer queue, but that rarely scales across browsers, mobile apps, and integrations. Optimistic concurrency assumes conflicts are uncommon: reads carry a version witness, writes include it, and the server rejects stale witnesses with a clear conflict response so clients can merge or refetch.

This pattern appears constantly in multi-tenant SaaS, public APIs, and BFF layers built for web and mobile clients. The implementation details—whether you surface ETags, numeric version columns, or hash digests—matter less than getting HTTP semantics, client behavior, and observability aligned. The sections below unpack how conditional requests work, how to choose status codes and payloads, and where teams usually get burned when they bolt concurrency onto an API that already has caching or aggressive retries.

Why optimistic concurrency belongs in the API contract

Pessimistic locking (row locks, distributed locks) guarantees mutual exclusion but adds latency, failure modes (who holds the lock?), and operational coupling. Last-write-wins is simple until correctness debt shows up in audits or support tickets.

Optimistic concurrency trades extra bytes on read and one conditional check on write for high throughput and clear conflict signals. It fits especially well when:

  • Humans edit the same resource through UIs that may lag or open multiple tabs.
  • Integrations replay or retry writes; you still need idempotency keys for duplicate HTTP attempts, but concurrency control addresses different logical intents racing on the same row.
  • Services fan out updates; a version witness helps downstream consumers detect skew without a central lock.

The contract is simple to state: every successful read that can be edited should return enough information for the client to prove “I knew the latest state when I sent this write.” The server enforces that proof.

Version witnesses: ETag, strong validators, and application versions

Strong vs weak ETags

HTTP validators come in two flavors:

  • Strong ETag — byte-identical representation for the negotiated response variant. Suitable for If-Match on full replacement semantics when the ETag truly tracks the body you are replacing.
  • Weak ETag (W/"…") — semantic equivalence, not byte identity. Some intermediaries normalize content; weak validators are still useful for caching, but be cautious using them as the sole gate for fine-grained field patches unless your server defines what “semantic equality” means for concurrency.

In practice, many JSON APIs use a strong ETag computed from a canonical serialization (stable key ordering, normalized numbers) or from a stored revision (rev:42, version:9) hashed into the validator string. What matters is monotonicity per conflict domain: two writes that should conflict must not both observe the same witness from the server’s perspective.

Application-level version fields

Exposing "version": 9 (integer) or "updatedAt": "…" alongside an ETag is redundant but debuggable. Mobile and web clients often persist a small projection in local storage; a numeric version is easy to log and display (“Someone else saved v10 while you were on v9”). Keep the authoritative check on the server in the database—UPDATE … WHERE id = $1 AND version = $2 returning row count zero should map to a conflict—not only in the JSON body.

Hash vs counter

  • Monotonic counter — cheap to compare, easy in SQL WHERE version = ?, and human-friendly. Requires a single writer path or atomic increment discipline.
  • Content hash — works when you do not want a dedicated column and can tolerate recomputing digests; watch CPU and canonicalization bugs that cause false conflicts.

Conditional requests: If-Match, If-None-Match, and PATCH semantics

If-Match on PUT (full resource replacement)

PUT /resources/{id} with If-Match: "9" expresses: “replace the resource only if the current ETag is still 9.” A mismatch yields 412 Precondition Failed in typical APIs (the precondition on the method failed). The client should refetch, merge, or prompt.

If-Match on PATCH (JSON Merge Patch / JSON Patch)

Partial updates are where teams accidentally ship non-atomic semantics. If your PATCH applies field-by-field without binding to a base version, you can still lose updates when two patches touch different fields unless the storage layer merges safely or you model patches as operations on a known base.

Common patterns:

  1. Require If-Match for every mutating PATCH — simplest story; conflicts if any concurrent change occurred, even to unrelated fields.
  2. Field-level merge rules — harder: you must define commutative merges per field type (counters, sets, strings) or expose CRDT-like behavior. Most business domains stick to (1) until they have a strong reason not to.

If-None-Match for create-uniqueness

If-None-Match: * on PUT to a new URL is the HTTP-native “create if absent.” Less common in JSON hypermedia APIs than POST with idempotency keys, but useful when the resource name is client-chosen and you want exactly-once creation semantics at a stable URL.

Status codes: 412, 409, and honest payloads

HTTP leaves room for interpretation; your docs should not.

  • 412 Precondition Failed — the If-Match / If-None-Match precondition did not hold. This is the most literal choice for failed conditional requests (RFC 9110).
  • 409 Conflict — resource state conflicts with the request; some teams prefer it because client libraries and proxies treat it similarly to 412 while the word “conflict” reads clearly in logs.

Pick one primary code per endpoint family and stick to it. Mixed behavior across resources frustrates SDK generation and retries.

Response body should carry:

  • currentVersion or currentEtag so the client can retry without another round trip if your policy allows.
  • conflictDetails when safe—e.g., which fields diverged, or a minimal diff. Avoid leaking another tenant’s data.

Also return ETag on successful reads and on successful writes so the client’s cache of the resource updates immediately.

Practical example: Express-style handler with SQL version check

The following sketch shows the intent: validate If-Match, perform an atomic UPDATE … WHERE version = $v, map zero rows updated to a conflict. It is simplified—production code adds authentication, tenancy scoping, metrics, and structured logging (themes that recur across the About page’s production-minded stack).

// Pseudocode: PUT /invoices/:id with If-Match: "v9" or If-Match: "9"
app.put("/invoices/:id", async (req, res) => {
  const invoiceId = req.params.id;
  const ifMatch = req.header("if-match");
  if (!ifMatch) {
    return res.status(428).json({ error: "If-Match required for invoice updates" });
  }
  const expectedVersion = parseEtagToVersion(ifMatch); // your mapping

  const body = validateInvoiceBody(req.body);

  const result = await db.execute(
    `
    UPDATE invoices
    SET lines = $1::jsonb, amount = $2, version = version + 1, updated_at = now()
    WHERE id = $3 AND tenant_id = $4 AND version = $5
    RETURNING version, amount, lines, updated_at
    `,
    [body.lines, body.amount, invoiceId, req.tenant.id, expectedVersion]
  );

  if (result.rowCount === 0) {
    const current = await db.query(
      `SELECT version, amount, lines FROM invoices WHERE id = $1 AND tenant_id = $2`,
      [invoiceId, req.tenant.id]
    );
    if (current.rows.length === 0) return res.status(404).end();
    return res.status(412).json({
      error: "version_conflict",
      message: "Invoice changed since you loaded it",
      currentVersion: current.rows[0].version,
      current: projectInvoice(current.rows[0]),
    });
  }

  const row = result.rows[0];
  res.setHeader("ETag", `"v${row.version}"`);
  return res.status(200).json(projectInvoice(row));
});

Why UPDATE … WHERE version = $expected? It makes the check-and-set atomic in one round trip. Race conditions between a separate SELECT version and UPDATE are a classic foot-gun.

Interaction with caching, BFFs, and CDNs

If GET /invoices/{id} is cacheable at a CDN while PUT flows to origin, ensure private caching for tenant-specific JSON and validate that ETag values survive your serialization stack. A middleware that strips ETags or reorders JSON keys will cause false conflicts or false successes.

For user-specific reads behind a CDN or browser cache, patterns from read-your-writes consistency with CDNs apply: short max-age, private, or no-store on editable projections, plus revalidation after writes. Optimistic concurrency does not fix stale reads from caches; it detects when stale writes would commit.

Relationship to idempotency keys

Idempotency keys deduplicate retries of the same request. Version witnesses detect concurrent different edits. You need both when clients retry POST/PATCH with side effects: the idempotency layer returns the first response for the same key, while If-Match still protects against a stale base document when the user edits offline and syncs later.

Order matters in storage design: accept idempotency replay after verifying the version still matches the intent captured with the idempotency record, or store the expected version inside the idempotency payload.

Common mistakes and pitfalls

  • ETag as decoration — Clients ignore it; your OpenAPI spec never documents If-Match as required. The result is last-write-wins with extra headers nobody sends.
  • Checking version only in application code — A TOCTOU between read and write; always enforce on write with a single conditional UPDATE (or equivalent transactional compare).
  • Weak validators for financial writes — Normalization changes that rotate ETags without real semantic changes cause churn; conversely, weak equality can mask conflicts. Pick semantics deliberately.
  • 412 without a recovery path — Clients need a story: refetch, three-way merge UI, or “copy my edits to clipboard.” Logging currentVersion helps support.
  • GraphQL updateFoo mutations without a expectedRevision — Same problem as REST; expose a version field on the type and thread it through mutations explicitly.

Conclusion

Optimistic concurrency turns “hope we do not clobber each other” into an enforceable contract: reads carry a witness, writes prove freshness, and conflicts surface as structured HTTP errors instead of silent data loss. The implementation centerpiece is almost always an atomic conditional write in the persistence layer, not clever middleware.

Done well, the pattern scales across web tabs, mobile offline sync, and partner integrations—especially when combined with clear documentation, idempotency for retries, and cache semantics that do not starve the client of fresh witnesses. For teams shipping production APIs and BFFs where correctness and scalability both matter, nailing this early saves painful retrofitting later. If you want help reviewing concurrency models or API contracts, the contact page is the best place to reach out.

订阅邮件通讯

新文章发布时收到邮件。无垃圾信息 — 仅本博客的新文章通知。

由 Resend 发送,可在邮件中退订。