Skip to main content

ETag Optimistic Concurrency

1. What this document is about

This document explains how to prevent lost updates (uninteded overwrites) in HTTP APIs by enforcing optimistic concurrency using ETags and If-Match request header.

It covers:

  • The real problem: concurrent writers + stale reads → silent overwrites
  • The HTTP mechanism: ETag as version, If-Match as precondition
  • A concrete, production-aware implementation in .NET / ASP.NET Core
  • Trade-offs, failure modes and operational implications

Where it applies:

  • Any API exposing mutate resources (PUT/PATCH/DELETE)
  • Systems where multiple clients can update the same resource
  • Systems where "last write wins" is not acceptable

Where it does not apply:

  • Append-only/event-sourced APIs where updates are modeled as commands/events
  • Single-writer resource (true single writer, not "usually single writer")
  • Scenarios where overwrites are acceptable and explicitly documented (rare)

2. Why this matters in real systems

You don't feel this problem when:

  • There's one client
  • Writes are rare
  • Payloads include all fields and clients always send the last state

You get hit in production when:

  • Multiple devices/users update the same entity (mobile, web, integrations)
  • Updates are partial and occur on different fields
  • Clients cache aggressively and replay old "save" actions
  • Retries happen due to network failures (especially with PATCH)
  • Background processes update records concurrently with user actions
  • Third-party integrators are sloppy (they will be)

What breaks when you ignore this:

  • A user changes "Address", another changes "Phone", one overwrites the other
  • A UI loads an entity, leaves tab open, later "Save" wipes newer changes
  • Integrations "sync" by overwritting whole entities from stale snapshots
  • You get "random" data regressions and nobody can reproduce them

Why simpler approaches stop working:

  • "We'll just use PATCH" doesn't solve stale state — PATCH can still be applied against a stale version
  • "The DB will handle it" only helps if you enforce concurrency at the DB boundary and surface it correctly at the API contract
  • "Last write wins" is a design decision. If you didn't explicitly choose it, you're just being careless.

3. Core concept (mental model)

Think of each resource presentation as having a version token

  • ETag = "This is the version of the representation you retrieved."
  • If-Match = "Only apply this update if the current version still matches what I read."

So an update becomes conditional:

"Update X only if noboody has modified X since I last fetched it."

If the condition fails, the server rejects the write with 412 Precondition Failed instead of silently overwriting.

This is not "locking". This is detecting conflicts at write time, cheaply.

Key invariant:

  • A wirte must never be accepted if the client is operating on stale state.
  • If a client wants to update, it must prove it knows the current version (or explicitly choose an override policy).

4. How it works (step-by-step)

Step 1 — Server returns an ETag on reads

On GET /resources/{id}, server returns:

  • ETag: "<token> (strong or weak)
  • Representation in the body

The token must change whenever the resource changes in a way that should invalidate a stale update.

Typical token sources:

  • rowversion / xmin / update counter
  • Hash of canonical representation (careful: expensive and easy to get wrong)
  • Explicit Version column incremented per write

Step 2 — Client includes If-Match on writes

On PUT/PATCH/DELETE,client sends:

  • If-Match: "<token-from-last-read>"

Now the write expresses a precondition.


Step 3 — Server validates precondition before applying changes

Server compares client token vs current token:

  • If match → apply update, generate new ETag, return it
  • If mismatch → reject with 412 Precondition Failed

Step 4 — Define the server behavior when If-Match is missing

This is where systems get sloppy.

If your requirement is "must pass If-Match, otherwise no overwrite", then missing If-Match must not be treated as "best effort".

You have two sane options:

  1. Strict: reject missing If-Match with 428 Precondition Required
  2. Migration-friendly: for a period, allow missing header only for specific clients/paths, behind a feature flag, then enforce globally

Step 5 — Return updated ETag on successful writes

Return:

  • 200 OK (or 204 No Content)
  • ETag: "<new-token>"

Clients should update their cached token.


Step 6 — Conflict resolution is the client's reponsibility (by design)

When receiving 412:

  • Client must re-fetch (GET)
  • Decide whether to retry with latest ETag
  • Possibly present a merge UI, or reapply changes against fresh state

Your API should privide enough info to make workable (details later).


5. Minimal but realistic example

Below is a minimal but production-awave approach in ASP.NET Core with:

  • GET returns ETag based on a DB rowversion
  • PUT requires If-Match
  • Write is rejected with 428 is missing header
  • Write is rejected with 412 is stale

Data model (EF Core style)


public sealed class Customer
{
public Guid Id { get; private set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;

// SQL Server rowversion (timestamp) maps to byte[].
// PostgreSQL alternative: xmin (unit) or a Version column.
[Timestamp]
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}

ETag helper

Use a stable encoding. For rowversion (byte[]), base64 if fine.

Importat: ETags in HTTP are quoted strings. Keep them quoted.


public static class ETag
{
public static string FromRowVersion(byte[] rowVersion)
{
var token = Convert.ToBase64String(rowVersion);
return $"\"{token}\""; //quoted
}

public static bool TryParseIfMatch(HttpRequest request, out string etag)
{
etag = "";
if (!request.Headers.TryGetValue("If-Match", out var values))
return false;

// We accept exactly one value for simplicity.
// If you need multiple, handle CSV parsing properly.
etag = values.ToString().Trim();

return !string.IsNullOrWhiteSpace(etag);
}
}

GET endpoint return ETag


app.MapGet("/customers/{id:guid}", async (Guid id, AppDbContext db, HttpResponse response) =>
{
var customer = await db.Customers
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id);

if (customer is null)
return Results.NotFound();

response.Headers.ETag = ETag.FromRowVersion(customer.RowVersion);

return Results.Ok(new
{
customer.Id,
customer.Name,
customer.Email
});
});

PUT endpoint requires If-Match and enforces it


public sealed record UpdateCustomerRequest(string Name, string Email);

app.MapPut("/customers/{id:guid}", async (
Guid id,
UpdateCustomerRequest body,
AppDbContext db,
HttpRequest request,
HttpResponse response) =>
{
// 1) Enforce presence of If-Match (your stated requirement)
if (!ETag.TryParseIfMatch(request, out var ifMatch))
return Results.StatusCode(StatusCodes.Status428PreconditionRequired);

// 2) Load current entity (tracked)
var customer = await db.Customers.FirstOrDefaultAsync(x => x.Id == id);
if (customer is null)
return Results.NotFound();

// 3) Compare If-Match with current ETag
var currentEtag = ETag.FromRowVersion(customer.RowVersion);
if (!string.Equals(ifMatch, currentEtag, StringComparison.Ordinal))
{
// Optional: include current ETag to help the client recover
response.Headers.ETag = currentEtag;

return Results.StatusCode(StatusCodes.Status412PreconditionFailed);
}

// 4) Apply update
customer.Name = body.Name;
customer.Email = body.Email;

// 5) Save - rowversion changes automatically
await db.SaveChangesAsync();

// 6) Return new ETag
response.Headers.ETag = ETag.FromRowVersion(customer.RowVersion);

return Results.NoContent();
});

What this maps to conceptually

  • GET gives the client a version token
  • PUT requires the client to prove freshness via If-Match
  • The server rejects stale writes deterministically
  • No locks, no long-lived sessions, just correct conditional semantics

Pratical notes you shouldn't ignore

  • If you PATCH, you must enforce the same rule, PATCH is not a "safe concurrency" endpoint by default.
  • Don't generate ETag from the serialized JSON output unless you control canonicalization and understand the cost.
  • If you have multiple representations (fields hidden by auth/scopes), your ETag must reflect what matters for updates, not what happened to be returned.

6. Design trade-offs

ApproachPrevents lost updatesClient complexityServer complexityOperational costFailure modes
ETag + If-Match (optimistic)✅ StrongMediumMediumLowClient needs retry/merge
DB-only optimistic concurrency (EF rowversion) without HTTP recondition✅ (server-side)LowLowLowAPI returns generic 409/500, clients can't reason well
Pessimistic lockingLowHighHighDeadlocks, lock contention, timeouts
Last-write-winsLowLowLowSilent data loss
Field-level merges on serverLowHighHighComplexity explosion, hidden merge rules

What you gain with ETag/If-Match:

  • Correctness under concurrency without locks
  • Protocol-level semantics that work across languages and clients
  • A clean contract: stale writes are rejected

When you give up:

  • Clients must handle 412 (re-fetch/retry/merge)
  • You must define versioning boundaries carefully (what changes ETag)
  • You must migrate existing clients if they currently write without preconditions

Hidden aceptance you're making:

  • Conflicts are normal in distributed systems; you're choosing to surface them instead of hiding them.

7. Common mistakes and misconceptions

Mistake 1 — Accepting updates without If-Match

  • Why it happens: "Some old clients don't send it."
  • What it causes: You reintroduce silent overwrites exactly where you need correctness.

How to avoid:

  • Return 428 for missing header
  • Use a feature flag / allowlist temporarily if you must migrate
  • Add telemetry to detect clients violating the contract

Mistake 2 — Returning ETag but not enforcing it

  • Why it happens: Teams treat ETag like caching metadata.
  • What it causes: False sense of safety. Clients think they're protected; they're not.

How to avoid:

  • Enforcement must be mandatory on writes, not optional.

Mistake 3 — Using weak Etags incorrectly

  • Weak ETags (W/"...") indicate semantic equivalence for caching, not byte-identical representation.
  • For concurrency, you typically want strong ETags (exact version).
  • If you can't guarantee strong representation semantics, base ETag on a server side version field, not reponse bytes.

Mistake 4 — Not defining what updates invalidate the ETag

If internal processes update "hidden fields", does it invalidate client writes? You need a clear rule:

  • If those changes can affect write correctness, ETag must change.
  • If they're orthogonal, you might version only the "client-writable state".

Ambiguity here creates either:

  • needless conflicts (too strict), or
  • lost updates (too permissive)

Mistake 5 — Not returning enough information on 412

If you return only 412 with no hints, clients guess. Better:

  • Include current ETag in response header
  • Optionally return a problem details body (RFC 7807 style) with a clear error code

8. Operational and production considerations

Observability signal you should add

At minimum:

  • Counter: http_precondition_required_total (428)
  • Counter: http_precondition_failed_total (412)
  • Rate: 412/total write attempts per endpoint
  • Breakdown by client app/version/tenant (if multi-tenant)

Why this matters:

  • A spike in 428 means client regression or a new integration violating contract
  • A spike in 412 means real concurrency contention or broken client retry logic
  • It's a leading indicator of user pain ("my saves fail")

What degrades first

  • UX: clients that don't re-fetch properly will crate "save loops"
  • Throughput: if clients naively retry without backoff, you get retry storms
  • Support: "random errors" unless error bodies are explicit and traceable

Backward compatibility strategy

If you alread have clients in the wild:

  1. Start returning ETags on reads
  2. Add server-side logging when writes arrive without If-Match
  3. Introduce 428 enforcement behind a feature flag per client
  4. Enforce globally once the blast radius is controlled

Client misuse you must assume

  • Clients will send stale tokens
  • Clients will cache GET responses longer than you expect
  • Clients will retry blindly
  • Clients will not implement merge unless forced

So you API must:

  • Fail deterministically (428 or 412)
  • Provide actionable errors
  • Avoid ambiguous "409 conflict" with no guidance

Security / proxy considerations

  • Ensure ETags don't leak senstive data (don't use raw hashes of sensitive payload)
  • Be careful with gateways/CDNs that may cache responses; set appropriate cache headers
  • Do not rely on client-provided tokens for anything other than equality check

9. When NOT to use this

Don't use ETag/If-Match enforcement when:

  • The resource is single-writer by construction (e.g. one service owns updates and no external writes exists)
  • Updates are expressed as append-only commands and you never replace state via API
  • The entity is "write-only" and overwirtes are acceptable and explicitly designed (rare)
  • The coast of client coordination is higher than the cost of occasional overwrites (also rare and you must say it out loud)

Don’t force this blindly across all endpoints:

  • Some endpoints represent idempotent operations or commands rather than state replacement.
  • If the endpoint is not “update resource state”, version preconditions may be the wrong abstraction.

10. Key takeaways

  • Lost updates are a protocol-level correctness bug, not a "UI issue".
  • ETag is the server's version token; If-Match turns writes into conditional updates.
  • If you require concurrency safety, reject missing If-Match with 428 and stale writes with 412.
  • Base Etags on server-controlled version (rowversion/version column), not on serialized JSON bytes.
  • Decide explicitly what changes invalidate the ETag — otherwise you'll create either false conflicts or silent overwrites.
  • Treat 412 rates as a production signal: tool high means either real contention or broken client behavior.
  • Plan migration: returning ETags is easy; enforcing If-Match safely requires telemetry and rollout discipline.

11. High-Level Overview

Visual representation of the end-to-end HTTP write flow, highlighting conditional requests, ETag-based optimistic concurrency, and explicit conflict signaling (428 / 412).

Scroll to zoom • Drag to pan
ETag + If-Match Optimistic Concurrency ETag + If-Match Optimistic ConcurrencyAPI BoundaryClientASP.NET Core APIConcurrency PolicyETag ServiceDatabaseClientClientASP.NET Core APIASP.NET Core APIConcurrency Policy(Require If-Match)Concurrency Policy(Require If-Match)ETag Service(Version Token)ETag Service(Version Token)Database(RowVersion/Version)Database(RowVersion/Version)Read: obtain current representation + version tokenGET/resource/{id}SELECTresource+versionresource+version=v1BuildETagfromv1ETag:"v1"200OKETag:"v1"Body:representationClient must store both:- representation snapshot- ETag token ("v1")Write: conditional update using If-MatchPUT/PATCH/resource/{id}If-Match:"v1"Body:changesEnforcepreconditions(If-Matchrequired+validate)alt[If-Match header is missing]missingIf-Match428PreconditionRequired(Explain:sendIf-Match)You must reject missing If-Matchto avoid silent overwrites.[If-Match present]SELECTcurrentversioncurrentversion=v2BuildcurrentETagfromv2currentETag:"v2"alt[If-Match != current ETag (stale write)]stalepreconditionclient="v1"server="v2"412PreconditionFailedETag:"v2"(optionalProblemDetails)Recovery (client responsibility):1) GET latest representation (ETag "v2")2) Re-apply changes (merge)3) Retry with If-Match: "v2"[If-Match == current ETag (happy path)]preconditionsatisfied("v1")UPDATEresourceWHEREid={id}ANDversion=v1updated,versionbecomesv2BuildnewETagfromv2ETag:"v2"204NoContentETag:"v2"Returning the new ETag enables safe next writes.Optional: client retry loop after 412opt[If client received 412]GET/resource/{id}SELECTresource+versionresource+version=v2BuildETagfromv2ETag:"v2"200OKETag:"v2"Body:representationPUT/PATCH/resource/{id}If-Match:"v2"Body:re-appliedchangesUPDATE...WHEREversion=v2updated,versionbecomesv3204NoContentETag:"v3"plantuml-src ZLPjSzf64FwkNx4bNs1IsqZWE6Q6pW36TQQ9hR59CvtXoo4TS8suK-vEODhfV--ktYKJZxyO-VPzdsVtpWeRSPWls0RUmwVrsHTcWoqyn5RiXB4YW9cIGQ8rby5nAUzO71_1PtQBshTCH8dcwRy6kaFl8rp0qFLwZcE-2nappNQmOi7tZLQ936SgKXgiPjAWY4jRqp9R5gg3a1jOiyZmcYGr3PIMN8FxeIPuuZyIJ8f_uSQm3Pz4OYCXGBTSrysvYPKqXTQKH-emqLeTC96a7cV7ddET9_4FX-j-lGZyYwqGCPFMPs58UNhzUf7gkJXtXtN3D5HM-BlhsMGmkQqB0wlqLEcGwrnbS3LqBzssogsIDbUONywlvjDw1BzAhUbgWAukJok-T7ZQNmkwT_sfwtcDnapLy-i5eC9hqSY-7SLrtO_ULJtAlRAy4MPuTNdvuRgbyLgSrCEBScxTAs_YE8oQ3BD887c06PWvY2Tq9ludp0_LcJvsohIWJWPKUlBFx-SBd18S1BJea0FyRUlLfWXy5GaQfkuZKbYWNJ5slTJQV_ZSjax7yudhlGWuslt9jH5Ama9zvp8pcYySBaCYeXCoorRCSEZSv5yKIXrogulyDxExGJADn_38MJW2jR9Cu12bYLgSdHZ71x-OfMZlOP_7jHGNncF7cS7PRrGkZE1txC85wgj41_pYNn7-vv00vJTJ53_DFyzd2oWKAcyEYizABo_bC8QzMxZ262EO9Y8AivMrrcf7OZolV91a19szsydCK333Ow_VXuS_bh8IB-LKXSTHgr37aSWMq6ApjQ3MI8US6hl4M32Ma5ufknqvP-qM6SbYirKM9MbwMQEw5AkNuaEUquP_qyBo4GHAXe9CMGH9ZCXnI0ojaN8JjxhiVrrS-9F5xAxLwwKiB9gb1LicDzmuHGEHNDGXkLRKP4o-pC0WIygeEcDcI32mI51YFOT5jifhYup1bImCu3r1AIDdoNa5GtvUcg127GwwBlnQVCYd8SGyviznX4mSWQ6cbUE17fhGP8FsbqeoR3J_cmVsL62hWEsL2C68YA1KIAu3GM3EKLeXm_7ogMhCiIqgQpAvc9A2nW0vaRCMjyJ4Oi-fFDM8N6ZM2EsXOaxffhU2krw7V3K0-MNSzDGrbk7DdbP9dMl3audh35ZA82Nwc0Y4hTUO-pYDHTODx5olYHqz1GYvAZz4KSKvitsjLX7VtN1SBv7fEQc_zf3HsIEwnA17w6QP5DUtM8b8s6Dlb6gvlNJPH4XAys9BTTFYAOEiQA-7Ni_msH8TYs60xexhJTwK0SajXZm8VDJKXoYlko87VZVeCMvtV9k-Zc9y3frgUBtLO31RinOuOVbsA5kSi-ohVpDPpCjzk9JVxkQFSq0AZo4R-CdzJND7ael6CiqsIlXhgRR237ROW8oAPS0Q6IK_j8bOKh6vNJCADddXzOTurTAJqM9NwdGu0Nasl0IwJRIaWRLRNYN09KFI6EpKcawVBLG3MqCb_QJTcgxLXvnoE345WGZRIAaOs1gVXYbrQQSYEH79IYtWOezee1XTldwjDO7w-SNMGkFqrUOvfs0ulGjU0D3kVlE2ytvwmJKgVDkLKdUMZXDnjxXTJbBt_FmSCjAsodqZHmVEsyWrw13y_mC0?>ETag + If-Match Optimistic Concurrency ETag + If-Match Optimistic ConcurrencyAPI BoundaryClientASP.NET Core APIConcurrency PolicyETag ServiceDatabaseClientClientASP.NET Core APIASP.NET Core APIConcurrency Policy(Require If-Match)Concurrency Policy(Require If-Match)ETag Service(Version Token)ETag Service(Version Token)Database(RowVersion/Version)Database(RowVersion/Version)Read: obtain current representation + version tokenGET/resource/{id}SELECTresource+versionresource+version=v1BuildETagfromv1ETag:"v1"200OKETag:"v1"Body:representationClient must store both:- representation snapshot- ETag token ("v1")Write: conditional update using If-MatchPUT/PATCH/resource/{id}If-Match:"v1"Body:changesEnforcepreconditions(If-Matchrequired+validate)alt[If-Match header is missing]missingIf-Match428PreconditionRequired(Explain:sendIf-Match)You must reject missing If-Matchto avoid silent overwrites.[If-Match present]SELECTcurrentversioncurrentversion=v2BuildcurrentETagfromv2currentETag:"v2"alt[If-Match != current ETag (stale write)]stalepreconditionclient="v1"server="v2"412PreconditionFailedETag:"v2"(optionalProblemDetails)Recovery (client responsibility):1) GET latest representation (ETag "v2")2) Re-apply changes (merge)3) Retry with If-Match: "v2"[If-Match == current ETag (happy path)]preconditionsatisfied("v1")UPDATEresourceWHEREid={id}ANDversion=v1updated,versionbecomesv2BuildnewETagfromv2ETag:"v2"204NoContentETag:"v2"Returning the new ETag enables safe next writes.Optional: client retry loop after 412opt[If client received 412]GET/resource/{id}SELECTresource+versionresource+version=v2BuildETagfromv2ETag:"v2"200OKETag:"v2"Body:representationPUT/PATCH/resource/{id}If-Match:"v2"Body:re-appliedchangesUPDATE...WHEREversion=v2updated,versionbecomesv3204NoContentETag:"v3"plantuml-src ZLPjSzf64FwkNx4bNs1IsqZWE6Q6pW36TQQ9hR59CvtXoo4TS8suK-vEODhfV--ktYKJZxyO-VPzdsVtpWeRSPWls0RUmwVrsHTcWoqyn5RiXB4YW9cIGQ8rby5nAUzO71_1PtQBshTCH8dcwRy6kaFl8rp0qFLwZcE-2nappNQmOi7tZLQ936SgKXgiPjAWY4jRqp9R5gg3a1jOiyZmcYGr3PIMN8FxeIPuuZyIJ8f_uSQm3Pz4OYCXGBTSrysvYPKqXTQKH-emqLeTC96a7cV7ddET9_4FX-j-lGZyYwqGCPFMPs58UNhzUf7gkJXtXtN3D5HM-BlhsMGmkQqB0wlqLEcGwrnbS3LqBzssogsIDbUONywlvjDw1BzAhUbgWAukJok-T7ZQNmkwT_sfwtcDnapLy-i5eC9hqSY-7SLrtO_ULJtAlRAy4MPuTNdvuRgbyLgSrCEBScxTAs_YE8oQ3BD887c06PWvY2Tq9ludp0_LcJvsohIWJWPKUlBFx-SBd18S1BJea0FyRUlLfWXy5GaQfkuZKbYWNJ5slTJQV_ZSjax7yudhlGWuslt9jH5Ama9zvp8pcYySBaCYeXCoorRCSEZSv5yKIXrogulyDxExGJADn_38MJW2jR9Cu12bYLgSdHZ71x-OfMZlOP_7jHGNncF7cS7PRrGkZE1txC85wgj41_pYNn7-vv00vJTJ53_DFyzd2oWKAcyEYizABo_bC8QzMxZ262EO9Y8AivMrrcf7OZolV91a19szsydCK333Ow_VXuS_bh8IB-LKXSTHgr37aSWMq6ApjQ3MI8US6hl4M32Ma5ufknqvP-qM6SbYirKM9MbwMQEw5AkNuaEUquP_qyBo4GHAXe9CMGH9ZCXnI0ojaN8JjxhiVrrS-9F5xAxLwwKiB9gb1LicDzmuHGEHNDGXkLRKP4o-pC0WIygeEcDcI32mI51YFOT5jifhYup1bImCu3r1AIDdoNa5GtvUcg127GwwBlnQVCYd8SGyviznX4mSWQ6cbUE17fhGP8FsbqeoR3J_cmVsL62hWEsL2C68YA1KIAu3GM3EKLeXm_7ogMhCiIqgQpAvc9A2nW0vaRCMjyJ4Oi-fFDM8N6ZM2EsXOaxffhU2krw7V3K0-MNSzDGrbk7DdbP9dMl3audh35ZA82Nwc0Y4hTUO-pYDHTODx5olYHqz1GYvAZz4KSKvitsjLX7VtN1SBv7fEQc_zf3HsIEwnA17w6QP5DUtM8b8s6Dlb6gvlNJPH4XAys9BTTFYAOEiQA-7Ni_msH8TYs60xexhJTwK0SajXZm8VDJKXoYlko87VZVeCMvtV9k-Zc9y3frgUBtLO31RinOuOVbsA5kSi-ohVpDPpCjzk9JVxkQFSq0AZo4R-CdzJND7ael6CiqsIlXhgRR237ROW8oAPS0Q6IK_j8bOKh6vNJCADddXzOTurTAJqM9NwdGu0Nasl0IwJRIaWRLRNYN09KFI6EpKcawVBLG3MqCb_QJTcgxLXvnoE345WGZRIAaOs1gVXYbrQQSYEH79IYtWOezee1XTldwjDO7w-SNMGkFqrUOvfs0ulGjU0D3kVlE2ytvwmJKgVDkLKdUMZXDnjxXTJbBt_FmSCjAsodqZHmVEsyWrw13y_mC0?>