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-Matchas 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
Versioncolumn 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:
- Strict: reject missing
If-Matchwith428 Precondition Required - 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(or204 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:
GETreturns ETag based on a DBrowversionPUTrequiresIf-Match- Write is rejected with
428is missing header - Write is rejected with
412is 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
GETgives the client a version tokenPUTrequires the client to prove freshness viaIf-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
| Approach | Prevents lost updates | Client complexity | Server complexity | Operational cost | Failure modes |
|---|---|---|---|---|---|
| ETag + If-Match (optimistic) | ✅ Strong | Medium | Medium | Low | Client needs retry/merge |
DB-only optimistic concurrency (EF rowversion) without HTTP recondition | ✅ (server-side) | Low | Low | Low | API returns generic 409/500, clients can't reason well |
| Pessimistic locking | ✅ | Low | High | High | Deadlocks, lock contention, timeouts |
| Last-write-wins | ❌ | Low | Low | Low | Silent data loss |
| Field-level merges on server | ✅ | Low | High | High | Complexity 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:
- Start returning ETags on reads
- Add server-side logging when writes arrive without
If-Match - Introduce 428 enforcement behind a feature flag per client
- 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 (
428or412) - 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".
ETagis the server's version token;If-Matchturns writes into conditional updates.- If you require concurrency safety, reject missing
If-Matchwith 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
412rates as a production signal: tool high means either real contention or broken client behavior. - Plan migration: returning ETags is easy; enforcing
If-Matchsafely 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).