Skip to main content

Command Palette

Search for a command to run...

Prevent lost updates with ETag and If-Match: optimistic concurrency in OpenAPI

Two clients PUT the same resource and the last write silently wins. The HTTP fix is conditional requests with ETag, If-Match, and a 412 that tells the client to re-fetch and retry. Here is the exact state table and the OpenAPI that makes codegen and AI agents handle conflicts cor

Updated
•6 min read•View as Markdown

Lost updates are the quietest data-corruption bug in an API. Client A opens an invoice, client B opens the same invoice, A saves a new line item, B saves a corrected address, and B's write overwrites A's line item entirely. Nobody gets an error. The fix is not a lock the client holds for minutes; it is an HTTP conditional request built on ETag and If-Match, and it belongs in the OpenAPI contract.

Optimistic vs pessimistic

Approach Mechanism Good when Cost
Pessimistic Server holds a lock until the client releases it High contention, long edits Lock expiry, dead clients, stateful server
Optimistic Client sends the version it read; server rejects stale writes Most CRUD APIs, short edits, mobile and agent clients One extra round trip on conflict

Optimistic concurrency is stateless, survives dropped connections, and maps directly onto two HTTP headers. It is the default you should document unless contention is near constant.

An ETag is a version token

Every GET of a mutable resource returns an ETag response header. Its value is an opaque quoted string that changes whenever the representation changes.

ETag: "3f8a91"
  • A strong validator, "3f8a91", changes whenever a byte changes and is safe for conditional updates.
  • A weak validator, W/"3f8a", may stay equal for semantically equivalent but byte-different bodies. Do not use a weak tag for If-Match on updates.

You do not promise the tag is a hash. It is often a row version number, a updated_at timestamp, or a content hash. Clients must treat it as opaque and send it back verbatim. Never let a client parse or construct one.

The conditional update flow

  1. The client GET /invoices/INV-42 and remembers ETag: "v3".
  2. It edits the body and sends PUT with If-Match: "v3".
  3. The server compares the header to the current version.
  • Equal: apply the write, return the new body and a fresh ETag: "v4".
  • Different: someone else wrote first; return 412 Precondition Failed and do not touch the resource.
  1. On 412, the client re-fetches, reconciles, and retries with the new tag.

For create-if-absent, send If-None-Match: *. The server succeeds only if nothing exists at that URL, returning 412 on a duplicate. That removes the create-versus-update race without a lookup-then-write gap.

Modeling it in OpenAPI

paths:
  /invoices/{invoiceId}:
    parameters:
      - $ref: '#/components/parameters/InvoiceId'
    get:
      operationId: getInvoice
      responses:
        '200':
          description: One invoice
          headers:
            ETag:
              $ref: '#/components/headers/ETag'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Invoice'
    put:
      operationId: updateInvoice
      parameters:
        - name: If-Match
          in: header
          required: true
          schema: { type: string }
          example: '"v3"'
          description: Version token from the most recent GET. Required for updates.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InvoiceUpdate'
      responses:
        '200':
          description: Update applied
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Invoice' }
        '412':
          description: Stale version; re-GET, reconcile, and retry with the new ETag
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '428':
          description: If-Match header is required for updates

Reusable headers keep the contract DRY:

components:
  headers:
    ETag:
      schema: { type: string }
      description: Opaque version token. Send it unchanged in If-Match on the next write.

Making If-Match required, and answering a missing header with 428 Precondition Required, forces every client into the safe path instead of silently accepting last-write-wins.

The state table worth pinning to the wall

Conditional logic looks fuzzy until you enumerate it. For a PUT with If-Match:

Header sent Resource state Server response
matches current tag exists 200 or 204, new ETag
does not match exists, changed 412, no write
* exists 412
* absent 201 created
omitted any 428 (when you enforce it)
matches absent 412 (the tag cannot match nothing)

For If-None-Match the polarity flips: a matching tag means "the representation is still current, skip the download" for a GET (304 Not Modified), which gives you free bandwidth savings on top of conflict protection.

Why this matters for AI agents

Autonomous agents retry writes blindly and hold no lock across a long reasoning step. That is exactly the workload optimistic concurrency is built for. A 412 is machine-readable: the agent can re-GET, diff its intended change against the new body, re-apply just that change, and retry with the fresh ETag. A bare 409 Conflict with no version header leaves the agent guessing whether the problem is the body, a uniqueness violation, or a stale write. Put the current tag in the error response or require the client to re-fetch, and the recovery loop becomes deterministic.

What code scanners miss

When you generate OpenAPI from code, concurrency contracts live in middleware, not in the function signature, so a syntax-only scan usually omits them. A reliable scanner traces the version column or @Version field, detects the If-Match guard and the branch that returns 412, and records the ETag response header. If the conflict path is constructed in a shared helper across packages and cannot be proven statically, that response should be reported as a gap, not silently dropped or invented.

Checklist

  1. Every mutable resource returns a strong ETag on GET.
  2. PUT and PATCH require If-Match; missing headers get 428.
  3. Stale tags get 412 with a problem body; the resource is never overwritten.
  4. Create-if-absent uses If-None-Match: *.
  5. GET honors If-None-Match with 304 for caching.
  6. The OpenAPI lists 200, 412, 428, and the current-tag header so clients and agents can implement the retry loop without reading your source.

With that table in the spec, lost updates stop being possible by accident.

Model conditional headers, generate a client that threads the ETag through GET and PUT, and exercise the 412 branch against a mock, all in the browser app. Concurrency rules are part of a broader change-management strategy; the full breaking-change and versioning workflow is covered in the REST versioning and CI diff guide.

More from this blog

P

Powerduck Blogs

117 posts

Essays on local-first API tooling, OpenAPI contracts, MCP, and agentic coding failures. We build Powerduck, a local-first OpenAPI studio where the spec stays the source of truth. Topics: AI code review trust, testing AI-generated code, context engineering, and what actually breaks when AI writes your code.