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

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.

``` text
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

``` yaml
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:

``` yaml
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](https://www.powerduck.com/app/?ref=powerduck.com). 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](https://www.powerduck.com/blog/rest-api-versioning-strategy-2026/).

