Backend engineer testing strong ETags and atomic If-Match checks to prevent lost updates in an API

Stop Lost Updates With Atomic If-Match Checks

Last updated: Sat, Sep 19, 2026

Concurrent edits can silently wipe out each other without visible errors. Imagine two authorized users both GET the same resource version (say a JSON document with fields {id, title, content} and ETag “v1”). Each makes different changes and then PUTs the updated document. If both writes are unconditional, the second write simply overwrites the first. This “lost update” scenario corrupts data without an obvious fault. To prevent it, the HTTP client’s intent (via an If-Match header carrying the original ETag) must be joined with an atomic database check of that version. Only when the stored representation exactly matches the ETag seen by the client should the update go through. In this way the semantic state transition (change from “v1” to “v2”) is accepted or rejected as one unit. We will use concrete (synthetic) example values and JSON, but note these are illustrative, not performance measurements. Crucially, we treat performance, caching, and authentication as separate concerns. Our focus here is correctness under race conditions: preserving each user’s edit unless a newer change has already won.

Reproduce a lost update with two authorized editors

First, illustrate the unsafe baseline. Define a simple resource, e.g. a “note” with content. Both Editor A and B are authorized to write. Initially:

GET /notes/42

→ Response: {"id":42,"content":"Original"}, ETag: "v1". Both A and B get this body and ETag. Now suppose they make different edits:

  •         Editor A: Changes content to “First edit” and issues the following request.

PUT /notes/42
Content-Type: application/json
Body: {"id":42,"content":"First edit"}

  •         Editor B: Changes content to “Second edit” and, almost simultaneously, issues this request.

PUT /notes/42
Content-Type: application/json
Body: {"id":42,"content":"Second edit"}

With no If-Match header, the server naively applies both PUTs in sequence. If A’s write is processed first, the store updates content="First edit" (implicitly moving to a new revision or ETag). Then B’s write overwrites it with content="Second edit". The final state reflects only B’s change; A’s update is lost. This happens despite both users being authorized, and despite an obvious conflict in their intentions.

To isolate correctness from speed or caching issues, this fixture does not use latency as evidence. It focuses on the semantic conflict. The invariant is that every applied update must start from the exact state the client read, so no intervening change goes unnoticed. That requires a version comparison joined atomically to the write. API performance as a separate acceptance dimension remains important, but it is outside this race-condition acceptance test.

Define what the representation and its ETag identify

We assume a documented JSON schema for the resource, for example { "id": <int>, "title": <string>, "content": <string>, "version": <int> }. The representation is the exact JSON served by GET within this fixture. Any change to those served bytes must produce a different strong ETag. The MDN ETag documentation describes an ETag as a representation validator used by conditional requests. The service may maintain a server-side revision counter or hash, but clients must treat the ETag as opaque.

A validator’s strength matters. The MDN If-Match reference explains that If-Match uses strong comparison, so a weak tag prefixed with W/ cannot satisfy the condition. The service must also avoid validator reuse. If a resource is deleted and recreated under the same external identifier, the new generation receives a fresh validator even if its revision counter restarts.

Invariant

What the service records

Acceptance rule

Resource identity

External ID plus tenant and generation scope

Identity alone never proves the version an editor read.

Revision

A server-controlled value advanced by every relevant mutation

No mutation path may change the served representation without advancing or replacing it.

Strong ETag

An opaque validator derived from the same state as the response body

It changes whenever the documented representation changes and is never reused across generations.

Example: the initial GET returns:

{"id":42,"title":"Hello","content":"Original","version":1}

with header ETag: "v1". If any represented field changes, the service produces a new validator. The client does not guess a counter. Under RFC 9110 HTTP Semantics, a strong validator must change whenever the origin server considers the prior and current representations observably different for a state-changing precondition.

Bind each read response to a trustworthy validator

Each GET response must deliver a body and its matching ETag from the same resource state. In practice this means using a single read operation or transaction to fetch data and compute the ETag together. For example, in PostgreSQL we might do:

SELECT data_column AS body, version
FROM notes
WHERE id = $id;

and then set ETag = '"' || md5(body || version) || '"' (for instance). The SQL query (or JSON-assembly) is atomic: the same snapshot yields both the JSON and the fields used to build the ETag. This avoids a race where one query reads the JSON, and another reads a version or hash afterward; a concurrent write in between could make them inconsistent.

Cover every mutation and representation change

Any update path must update the version and thus the ETag. This includes explicit edits via PUT, any admin or maintenance changes, or auxiliary modifications (logging updates, etc.). Every code path that changes the resource should derive a new ETag. If there are multiple JSON encodings (e.g. pretty-printing vs compact), pick one canonical form for storage so the validator stays in sync. (Content-negotiated or compressed variants could have separate ETags, but that complexity is beyond this scope.)

Return body and tag from the same state

Never split into two queries. For instance, this is wrong:

BEGIN;
SELECT data FROM notes WHERE id=42;   -- read at some time
-- (Another transaction could update here)
SELECT version FROM notes WHERE id=42; -- read again, possibly later
COMMIT;

The body and revision can diverge if a concurrent write commits between separate reads. A single statement avoids that pairing error. The PostgreSQL 18 transaction-isolation documentation states that each command under Read Committed sees a snapshot as of the command’s start. One combined SELECT therefore returns body and revision from the same statement snapshot.

Make the comparison and mutation one atomic decision

The core fix is to let the database do the version check and update in one step. In PostgreSQL, the simplest approach is a conditional UPDATE... WHERE version = $expected_version. For example:

PostgreSQL-specific fixture assumptions: the request is already authenticated; the caller has passed object-level authorization; version advances with every represented mutation; the ETag resolves server-side to one expected revision; and the statement runs in a transaction with ordinary error handling.

UPDATE notes
   SET data = $newData, version = version + 1
 WHERE id = $id
   AND tenant_id = $tenant_id
   AND version = $old_version
RETURNING version;

Here $old_version is resolved by the server from the client’s opaque ETag. The statement scopes the row by resource key, tenant, and expected revision, changes the data, advances the revision, and returns the accepted transition. Under Read Committed, PostgreSQL re-evaluates the UPDATE search condition on the updated row after a concurrent updater commits. A stale predicate therefore affects zero rows instead of overwriting the winner.

A separate SELECT followed by an unconditional UPDATE leaves a race window between the check and the write. The conditional statement closes that window at the API-to-database integration boundary. This SQL is a PostgreSQL example, not a portability guarantee for every storage engine.

Include authorization scope in the storage predicate

In a multi-tenant or multi-user setup, incorporate authorization into the WHERE clause. For example, WHERE id=$id AND tenant_id=$tenant AND version=$old_version. If the tenant_id (or owner ID) doesn’t match the requestor, no row will match. In that case we should not reveal whether the resource existed at all; we simply return not-found (404) or unauthorized (403) according to policy, and not a 412. In other words, a missing row (no update) could mean “not your resource” or “version mismatch,” but we must not leak which. A typical pattern is: if the combined key (id+tenant) is absent, respond 404; if present but version differs, respond 412.

Coordinate a race and verify a single accepted version

We can validate our design with a controlled race test. Start two transactions (Editor A and B) concurrently:

-- Both editors do this in parallel:
BEGIN;
SELECT version FROM notes WHERE id=42;  -- both see version=1

Now suppose both try the conditional update at the same time:

-- Editor A:
UPDATE notes SET data='editA', version=2
 WHERE id=42 AND version=1;

-- Editor B:
UPDATE notes SET data='editB', version=2
 WHERE id=42 AND version=1;

Because these run in parallel, one UPDATE will commit first (say A’s). When B’s UPDATE finally executes, PostgreSQL will have seen the commit from A. It then re-evaluates B’s WHERE version=1 against the updated row (now version=2). The condition fails (2≠1), so B’s UPDATE affects 0 rows. B’s transaction ends with no change. In effect, only one editor’s change is applied, as expected.

Expected results (conceptual): one UPDATE returns version=2; the competing UPDATE returns no row. The final state remains at version=2 with the winning content. These are proposed observations, not executed output. If a controlled run produces them, it demonstrates that only one matching-version transition was accepted and the stale transition was held.

Distinguish failed and missing preconditions

We now have two error conditions to handle: a stale If-Match vs a missing If-Match header when one is required. By HTTP standards, these use different status codes.

  •         Supplied condition fails: The client sends an If-Match value, but no current strong validator matches it. RFC 9110 requires the server to evaluate the precondition before performing the method. This fixture returns 412 Precondition Failed and does not mutate the resource.

HTTP/1.1 412 Precondition Failed
Content-Type: application/json

{"error":"Stale version","message":"Resource has changed since you fetched it."}

  •         Required condition is missing: The API’s documented policy requires If-Match, but the request omits it. RFC 6585 defines 428 Precondition Required for a server that requires a conditional request.

HTTP/1.1 428 Precondition Required
Content-Type: text/html

<html>
  <body>
    <h1>Precondition Required</h1>
    <p>This request must include an If-Match header with the current ETag.</p>
  </body>
</html>

The RFC 6585 definition of 428 Precondition Required says the response should explain how to resubmit successfully and must not be stored by a cache. Requiring this precondition is an application policy, not a universal rule for every PUT endpoint.

Weak entity-tags never match under If-Match strong comparison. In addition, RFC 9110’s If-Match semantics define If-Match: * as an existence test. The wildcard does not prove that the resource still matches the exact version an editor read. This fixture therefore requires a specific strong tag for edit protection and returns 412 when a supplied wildcard is unacceptable under policy.

Request condition

Meaning

Fixture response

Write outcome

If-Match absent

Required precondition was not supplied

428 Precondition Required

No mutation

Current strong tag

Exact version the editor read still matches

200 or 204

Apply one atomic transition

Stale or weak tag

Strong comparison fails

412 Precondition Failed

No mutation

If-Match: *

A current representation exists, but exact read-version identity is unproven

412 under this strict edit policy

No mutation

The status distinction is deliberate: 428 identifies an omitted required condition, while 412 identifies a supplied condition that did not permit the requested transition. RFC 9110 also permits a bounded already-applied success response in specific verified circumstances; this fixture’s ordinary stale-edit path remains a stringent 412 policy.

Recover a conflict without silently overwriting work

When an update fails with 412, the client (or user) needs guidance to reconcile changes. Simply retrying with the new tag would silently overwrite the conflict we detected. Instead, we must preserve both versions. A typical flow is:

Recovery stage

Required action

Evidence retained

1. Hold

Keep the rejected request and unsaved user intent; do not replay it automatically.

Original ETag, attempted fields, correlation ID

2. Reread

Perform an authorized GET for the current representation and validator.

Current body and current strong ETag

3. Reconcile

Let the editor compare, merge, discard, or replace intentionally.

Explicit user or workflow decision

4. Resubmit

Send a new conditional request using the fresh tag only after reconciliation.

New request body and new precondition

  •         Conflict response: Return 412 with a stable error code and recovery guidance. Return current content only when object-level and field-level authorization permit it; otherwise require a separate authorized GET.

HTTP/1.1 412 Precondition Failed
Content-Type: application/json

{
  "error": "conflict",
  "current": {"id":42,"content":"Newer text","version":3},
  "yourChange": {"id":42,"content":"My edit"}
}

This tells the user: “Your edit (“My edit”) conflicts with the current state (“Newer text”).” (The version or ETag shown is the one just fetched.) The message should not merge automatically; it should simply report the discrepancy.

Require an intentional rebase or new submission

The client (UI or script) presents the conflict to the user. The user can merge changes if possible (e.g. combining both edits) or abandon one. The key is they then issue a new, deliberate PUT using the updated ETag. In other words, the user’s intent (“set content = My edit”) must be explicitly re-applied against the latest state. Do not have the client automatically fetch a new ETag and replay the old change; that would repeat the overwrite.

Example flow: Alice submits a PUT with content “Fix A,” gets 412. The app now shows current content “Fix B” (from someone else’s update) and Alice’s pending “Fix A.” Alice decides to merge them into “Fix A + B” and resubmits. The new PUT uses the fresh ETag obtained from the authorized reread.

This explicit rebase or “retry” ensures the user consciously handles the conflict. The server will accept the new PUT (with the current ETag) and advance to a new version. Importantly, this new ETag must be the one the client just saw, not the stale one.

Handle an uncertain response after an applied request

If a client does PUT and then loses the connection or times out, it may not know if the server applied it. It should check the result rather than assume failure. For example, it can GET /notes/42 (or HEAD) to see the current ETag/content. If the current state includes the intended change (or the ETag matches the next version), the client can conclude its PUT succeeded. If it still has the old ETag, the write likely failed. In doubt, the client should fetch the latest state and proceed from there. This avoids the danger of unknowingly duplicating a change or ignoring a partial failure. RFC 9110’s already-applied allowance is narrow: the server must verify that the requested state change has already succeeded before using a success response. A timeout alone is not that proof.

Test deletion, recreation and validator reuse

Edge cases around resource lifecycles must be covered:

  •         Deletion: If a client sends If-Match: "<old_etag>" for a resource that has been deleted, the condition fails (no current representation). The appropriate response is 412 (or 404 if you choose to treat missing as not found). The main point is not to accidentally accept an update on nothing.

  •         Recreation under the same ID: Suppose resource 42 is deleted and then a new resource 42 is created with fresh content. Even though the id is the same, this is a new generation. Its ETag must not match the old. A stale If-Match from the old version must fail. For example, after recreation, a PUT with the old ETag returns 412. The new creation starts at a base version (e.g. version 1 again) with a new ETag.

  •        Multiple If-Match values: The spec allows multiple ETags in If-Match (true if any match). We typically expect a single tag. But if a client supplies If-Match: "v2", "v3", the condition is true if the current ETag is either v2 or v3. That can be useful in some API designs, but in our model we want the current version only. We won’t rely on lists, and we certainly won’t accept an older version just because it’s one of many offered. It’s safer to require a single exact match. The syntax is defined by RFC 9110, but the fixture expects one exact validator for a single edit base.

  •         Wildcard existence check: As noted, If-Match: only checks existence. We deliberately avoid using it for updates. If needed for “create only” semantics, If-None-Match: (inverted logic) is the proper way, but that’s a separate topic.

Each of these cases should be explicitly tested. For instance, try updating after a DELETE (expect 412/404), or after a new PUT created the resource (expect 412), and ensure an old validator never accidentally matches a new generation.

Respect PUT validator rules across the response path

RFC 9110’s successful PUT validator rule prohibits an origin server from sending a validator field in a successful PUT response unless the submitted representation was saved without transformation and the validator reflects the resulting representation. If the service sorts JSON keys, injects defaults, or otherwise normalizes storage, it must not return a convenient ETag for bytes that were not stored as submitted.

In our implementation, we normalize JSON on write. Thus after a successful conditional PUT, we do not emit a new ETag header in the PUT response. Instead, the client must issue a subsequent GET /notes/42 to obtain the final JSON and its ETag. Example:

PUT /notes/42
If-Match: "v2"
Content-Type: application/json
Body: {"id":42,"content":"New text"}

Assuming it succeeds, we reply:

HTTP/1.1 204 No Content

No ETag header is returned. The client then performs:

GET /notes/42

The GET returns the authoritative normalized JSON and its matching strong validator. A gateway can preserve, remove, or misconfigure headers, so gateway and production API responsibilities must include end-to-end header verification. The gateway still cannot make a separate database comparison and mutation atomic.

Keep concurrency, authorization and caching separate

It’s important that If-Match’s role is only concurrency control. Authentication and authorization must be checked independently. For example, even if a request has a correct ETag, if the user isn’t allowed to modify that resource, we must reject it (404 or 403) before evaluating the precondition. Don’t assume a valid If-Match grants permission. As a rule: authenticate, authorize, then check ETag preconditions.

ETags are validators, not credentials. Preserve API authorization and observability controls before evaluating a state-changing precondition. If the caller lacks access, follow the API’s non-disclosure policy instead of revealing whether a validator or resource exists.

Caching is also different: If-None-Match (not If-Match) is used for read caching, yielding 304 responses. In our write scenario we ignore caching semantics. We recommend that middlewares or gateways validate caches separately and do not interfere with our PUT logic.

For observability, log conflicts or conditional-failures in a sanitized way. For each Precondition Failed event, record an entry with at least: a request correlation ID, the resource key (e.g. “notes/42”), the provided ETag and the current ETag, plus user/tenant info if needed. Do not log the actual content. An example event record might look like:

{
  "event":"etag_conflict",
  "requestId":"abc123",
  "resource":"notes/42",
  "user":"tenant7_user15",
  "ifMatch":"\"v2\"",
  "currentETag":"\"v3\"",
  "status":412
}

This event shows why the request was held without recording either editor’s full JSON. Local monitoring can aggregate the outcome by resource surrogate, client, or workflow. Alert thresholds must be derived from the application’s normal editing behavior rather than invented as a universal conflict-rate standard.

Build an acceptance matrix for real competing writes

An API is only as good as its tests. We should explicitly enumerate and test all relevant scenarios. For instance:

Test case

If-Match header

Expected status

Final resource & ETag

Valid update

If-Match: "<current>"

200 OK (or 204)

Data changed, ETag advanced

Stale update

If-Match: "<old>"

412 Precondition Failed

Data unchanged, ETag unchanged

Missing If-Match

none

428 Precondition Required

Data unchanged

Weak ETag used

If-Match: W/"<tag>"

412 Precondition Failed

Data unchanged

Wildcard *

If-Match: * (policy forbid)

412 Precondition Failed

Data unchanged

Simultaneous same-version

Two concurrent If-Match:v1

One 200/204 + one 412

Data updated by one, version+

Concurrent separate resources

different IDs

both 200 OK

Each updated separately

Delete then update

If-Match:"<old>" after DELETE

412 or 404

No change (resource gone)

Recreate under same ID

If-Match:"<old>" on new

412

Original ETag is obsolete

Normalized PUT

If-Match:"<v2>", content reformatted

204 then GET 200

ETag consistent with stored JSON

The matrix is illustrative until executed against the target service. Sequential request and response checks with Postman can validate syntax, ordinary status handling, and final representations. The same-version writer case needs coordinated workers or database barriers so both requests start from the same validator. Record each request, status, final data, final validator, and side effects.

Observe conflicts without leaking resource content

Conflict events should generate sanitized logs or metrics. For each 412 or 428, log the event with just identifiers and status. For example:

Timestamp

Status

Resource surrogate

If-Match

Accepted/current tag

Outcome

2026-09-18T12:34:56Z

412

note:8f2c

"v2"

"v3"

Stale update held

2026-09-18T12:35:10Z

428

note:b114

(missing)

(not disclosed)

Required header absent

This “conflict ledger” shows request ID or correlation, the resource key (but no full data), the precondition result and accepted version. You might include user or tenant fields as needed. The goal is to distinguish benign contention (multiple users editing the same note) from an error condition (say, a rogue client repeatedly sending stale tags). Over time, you can set alerts if any single resource or user sees an unusual rate of 412s. (There is no one-size-fits-all threshold; define your own, e.g. “alert if >5 conflicts on note 42 in 5 minutes”).

In summary, track attempts and outcomes, but keep them opaque. Note especially that log entries should not contain the full JSON content from either side; only IDs and tag values. This preserves privacy while providing insight. Proper tagging of log levels (e.g. mark repeated 412s as WARNING rather than ERROR) can also help focus attention on actionable issues.

Roll out conditional writes without removing the safeguard

Introducing If-Match checks requires coordination with client developers. Consider a staged rollout:

  •         Capability discovery: Early on, ensure client code can supply If-Match and handle 412/428. Use a feature-flag or versioned API header (e.g. Prefer: return=representation) if needed to negotiate.

  •         Staged enforcement: At first, accept writes without If-Match but log them. Then switch to optional If-Match: allow both conditional and unconditional updates, but always include an ETag on reads. Finally, make it required; respond 428 for any write missing the header. This gives clients time to upgrade.

  •         Fallback for legacy clients: If some clients can’t be updated, consider a limited “compatibility path” (for instance, queue their updates for manual merge or expose an admin endpoint). But note: any path that bypasses the version check reintroduces lost-update risk. It’s safer to push clients to support the header than to allow silent overwrites.

  •        Rollback implications: If you ever disable the If-Match requirement (for example, to handle an outage), be aware that all past conflict protections vanish. Any writes that occurred during the failed period should be reviewed for lost updates. It’s best to avoid rollback of conditional logic unless absolutely needed, and if so, reconcile any uncertain writes first.

Rollout checklist:

1.        Verify that each supported client can retain a GET ETag, send If-Match, and handle 412 and 428.

2.        Update the API contract with the conditional-write policy and documented recovery behavior.

3.        Deploy telemetry for missing, failed, accepted, and uncertain preconditions.

4.        Communicate the enforcement date and migration path to integrators.

5.        After the compatibility period, require If-Match and return 428 when it is absent.

6.        If a client still cannot comply, hold or limit its writes. Do not silently restore unconditional updates.

7.        Before reopening traffic after an incident, reconcile requests whose response outcome is uncertain.

Develop API foundations for concurrency-safe workflows

Concurrency-safe APIs depend on clear REST and GraphQL design, consistent authentication and authorization, reliable database integration, documented errors, and repeatable testing. The Refonte Learning APIs Developer Program page lists a three-month format at 10–12 hours per week and names REST and GraphQL, authentication and authorization, database integration, documentation, testing, error handling, logging, and API versioning and deprecation among its topics.

  •         Use those foundations to document who owns validator generation, atomic storage predicates, client recovery, and release acceptance for every conditional-write endpoint.

Accept a state transition, not just a header

The acceptance target is a state transition, not the presence of an If-Match header. The GET body and strong ETag must represent the same state. The write path must compare the expected version and mutate that state in one atomic storage decision. A coordinated two-writer test should produce one accepted transition, one held stale request, and an unchanged winning result.

The rejected editor also needs an explicit recovery path: preserve the pending edit, perform an authorized reread, reconcile deliberately, and submit a new conditional request. This fixture covers one JSON resource, one documented representation, and one PostgreSQL-specific update pattern. Other endpoints remain unproven until their mutation paths, validators, authorization boundaries, and recovery behavior receive the same review.

  •         Read evidence: body and strong validator originate from one consistent resource state.

  •         Write evidence: the authorized resource scope and expected revision are part of one atomic predicate.

  •         Recovery evidence: a stale editor cannot overwrite the winner without an intentional new submission.