ETag: The Concurrency Guard Hiding in a Cache Header
Ask most engineers what an ETag is for and you will get the caching answer: the server sends a fingerprint of the resource, the client sends it back in If-None-Match, and the server replies 304 Not Modified instead of the body. That is correct and it is the smaller half of the header's job.
The larger half is that the same fingerprint can be attached to a write, where it stops one client from silently destroying another client's change. The HTTP specification names the failure it exists to prevent, and it is not a subtle one: to prevent the "lost update" problem. The header you filed under performance is also a data-integrity mechanism.
This article builds that mechanism end to end and then runs it: a version column in SQL, an optimistic-locking exception in PHP, a precondition handler in Go, and two real goroutines racing for one resource. Every code block below is a real file in a repository you can clone, and the outputs are the actual outputs, not illustrations.
It is also the prequel to a piece about Dapr's state management API, and the connection is the reason I wrote it in this order. Inserting a sidecar between your service and its store is a decision about where the guarantee lives. You cannot judge that decision until you have seen the guarantee assembled from its parts, because the parts are what get abstracted away.
ETag is not a cache header
There are two conditional request headers built on entity tags, and they are not variations on a theme. They answer different questions, and the specification imposes different comparison rules on them, which is where implementations quietly go wrong.
For reads, RFC 9110 section 13.1.2 defines If-None-Match and requires the weak comparison function: a recipient MUST use the weak comparison function when comparing entity tags for If-None-Match, since weak entity tags can be used for cache validation even if there have been changes to the representation data. A cache does not care whether the bytes are provably unchanged. It cares whether the representation it holds is still the one the origin would produce.
For writes, section 13.1.1 defines If-Match and requires the opposite:
- An origin server MUST use the strong comparison function when comparing entity tags for If-Match, since the client intends this precondition to prevent the method from being applied if there have been any changes to the representation data.
- The field is most often used with state-changing methods (e.g., POST, PUT, DELETE) to prevent accidental overwrites when multiple user agents might be acting in parallel on the same resource (i.e., to prevent the "lost update" problem).
- A false condition means the server MUST NOT perform the requested method.
So the header is a cache validator on GET and a concurrency guard on PUT, and the difference is not cosmetic. A server that hands out validators which cannot satisfy strong comparison has a working cache and no concurrency control at all — and nothing about the read path will tell you that. The write path is where you find out, which is the worst time to find out.
The lost update in one interleaving
The lost update needs two clients and no malice. Both read a resource, both compute a new state from what they read, both write it back:
T1 GET /orders/1 200 {"id":1,"status":"new","version":1}
T2 GET /orders/1 200 {"id":1,"status":"new","version":1}
T1 PUT /orders/1 paid 204 written
T2 PUT /orders/1 shipped 204 written, and T1's write is gone
Nothing failed. Both clients received 204 No Content. The database is consistent. The only casualty is a write that has ceased to exist, and there is no log line for it, because from the server's point of view each request was perfectly valid in isolation. That is what makes this class of bug expensive: it produces no error, no alert, and no stack trace. It produces a customer who swears they marked the order as paid.
RFC 6585 section 3 describes the same sequence in prose, describing the status code it introduces for exactly this scenario: a client GETs a resource's state, modifies it, and PUTs it back to the server, when meanwhile a third party has modified the state on the server, leading to a conflict.
The read-modify-write shape is not confined to human-driven forms. It is the shape of any background job that loads a row, decides, and saves; of any two internal services touching the same aggregate; of any retry after a timeout whose first attempt actually succeeded. The writer that causes your worst lost update is usually not a person with two browser tabs. It is a second process nobody thought of as a concurrent writer, because it has no user interface and appears in no sequence diagram.
Why pessimistic locking does not scale
The direct fix is to lock the row before reading it. It works, it is available in every relational database, and on a web request that holds a form open it is a way to take your service down.
BEGIN;
SELECT status, version FROM orders WHERE id = 1 FOR UPDATE;
-- decide, then write
UPDATE orders SET status = :status WHERE id = 1;
COMMIT;
The lock lives as long as the transaction, not as long as the logic. Postgres holds a FOR UPDATE row lock until commit or rollback. If the transaction spans a user's think-time — load the form, wait for the human, submit — then the lock, the connection, and a slot in the pool are all held for the duration of a person reading their screen. Twenty abandoned form tabs in front of a twenty-connection pool is a full outage caused by human latency, and it is a self-inflicted one, because no database lock was ever designed to be held across a person.
That failure compounds with the pool arithmetic covered in the connection pooling article: the scarce resource is not the row, it is the connection holding it. Pessimistic lockers tend to discover pool tuning as a symptom of a locking design.
Deadlocks appear as soon as lock ordering stops being uniform. Two transactions locking rows in different orders will deadlock, and the database will resolve it by killing one of them. You get a correct system with a random failure rate, plus the code to handle aborts that you now need anyway.
And it cannot span a process boundary. The strongest argument is not performance, it is topology. A lock in your database is invisible to a client, to a queue consumer in another service, and to an offline mobile app. The optimistic approach works precisely because it needs nothing held: the right to write is carried in the request as data, in a header, and can therefore survive a network gap, a queue, and a cold start.
Pessimistic locking is the right tool for short, server-side critical sections — a counter under heavy contention, a work queue drained with FOR UPDATE SKIP LOCKED. The mistake is applying it to a critical section whose duration is a human being.
Optimistic concurrency control as a trade
Optimistic concurrency control inverts the question. Instead of preventing a conflicting write, it makes the conflict detectable and lets the loser fail loudly. Four properties fall out of that inversion, and each one is a cost you are agreeing to pay:
- No lock is held between the read and the write. Any process may read; nothing blocks. This is what makes the pattern survive long interactions and network boundaries.
- The conflict is detected at write time, by the store. The version the writer read becomes part of the write's predicate, so the check and the mutation are one operation.
- The loser receives an error, not a silent overwrite. That is the entire value proposition: a failed write you can see beats a successful one that ate someone's change.
- Retrying is application logic. The store tells you the write did not apply; deciding whether to reload, re-apply, merge, or surface a conflict to a human is a product decision the database cannot make for you.
The atomicity requirement is the part that is easy to get wrong. It is not enough to read the version, compare it in your code, and then write: that sequence contains the same race one level up, just narrower. Two processes can both read version 3, both compare 3 to 3, both decide they may proceed, and both write. The comparison has to be inside the same atomic operation as the write, which means inside the SQL statement, evaluated by the database, where no interleaving is possible.
A version column, not a local counter
The state of the resource carries the version it was read at. In PHP 8, that is one promoted property, and the interesting design decision is what not to do with it:
<?php
declare(strict_types=1);
namespace Shop\Ordering;
final class Order
{
public function __construct(
public readonly int $id,
public readonly string $status,
public readonly int $totalMinor,
public readonly int $version,
) {
}
public function withStatus(string $status): self
{
return new self($this->id, $status, $this->totalMinor, $this->version);
}
}
withStatus() returns new state and keeps the same version. That looks like an omission and is the whole point: the in-memory version is not a counter this process owns, it is the predicate of the update it is about to attempt. Two writers who both read version 1 must both present version 1, so that the second one's update matches zero rows and is refused. A local increment — the instinctive "I changed the object, so version++" — would give each writer its own private version number, every predicate would match, and every conflict would vanish. The counter has to be advanced by exactly one authority, and that authority is the database.
The compare-and-set moves into the store
The predicate and the mutation become a single statement, which is what makes the check unraceable:
public function save(Order $order): Order
{
$stmt = $this->pdo->prepare(
'UPDATE orders
SET status = :status, version = version + 1
WHERE id = :id AND version = :expected_version'
);
$stmt->execute([
'status' => $order->status,
'id' => $order->id,
'expected_version' => $order->version,
]);
if ($stmt->rowCount() !== 1) {
throw new OptimisticLockException($order->id, $order->version, $this->version($order->id));
}
return new Order($order->id, $order->status, $order->totalMinor, $order->version + 1);
}
Three details make this production-grade rather than illustrative.
The version is incremented by the database, in the same statement. Not read back, not computed in PHP, not incremented in memory. There is no window between reading and writing for anything to change, because there is no read.
rowCount() !== 1 is the conflict signal, and on MySQL that requires care. PDO's row count for MySQL reports the number of changed rows by default, not the number of matched rows — the PDO::MYSQL_ATTR_FOUND_ROWS attribute is off unless you turn it on (see the pdo_mysql documentation). A naive UPDATE orders SET status = :status WHERE id = :id AND version = :version therefore returns zero for a write that matched but happened to store the same value, and your application would raise a conflict for a change that succeeded. The version = version + 1 in the statement is what makes this safe: a matched row is always a changed row, because the version always moves, so zero reliably means no match. If you copy this pattern without the version increment, you inherit a false-conflict bug that only fires when someone saves a form without editing it.
The exception carries the version that actually won.OptimisticLockException holds $expectedVersion and $actualVersion, because the caller's retry decision depends on knowing both. An error that says only "conflict" forces the caller to re-read the row to learn what it already needed, which is a wasted round trip on the hottest error path you have.
Run the two-writer scenario against SQLite and the mechanism reports itself:
=== 1. Two writers hold the same read of the same row ===
alice read version 1, bob read version 1
alice PUT status=paid -> 1 row, version 1 -> 2
bob PUT status=shipped -> rejected: Order 1: expected version 1, found 2
row now holds status=paid, version=2
strong validator the transport layer can mint: "order-1-v2"
Bob is not told to try again blindly, and Alice is not told anything at all — her write is the one that stands. The asymmetry is the requirement: exactly one writer may move a version, and both of them learn which one that was.
Version counter or content hash
There is a tempting alternative to adding a column: derive the validator from the resource itself. Hash the representation, send the hash, and compare it on write. No schema change, no counter, and the validator is meaningful to a human reading a log line.
It is also not a strong validator, and the reason is in the definition rather than in an opinion. RFC 9110 section 8.8.1 states the requirement a strong validator must meet: a strong validator is unique across all versions of all representations associated with a particular resource over time. A hash of the current content satisfies "unique across representations" and fails "across all versions over time", because content can recur. A resource that moves A → B → A has produced the same hash twice, and a client holding the hash from the first A now appears to hold a current validator.
That is not a theoretical objection to hashing; it is a demonstrated way to lose a write. Two ordinary commits — a status moved forward and back — restore the old hash, and a stale writer's precondition matches again:
=== 2. Hash of the current state as the validator ===
alice read state 'new', validator 11507a0e2f5e
carol PUT new->shipped -> committed
carol PUT shipped->new -> committed (state is 'new' again, two commits later)
alice PUT status=cancelled -> 1 row changed; validator still matched, stale write accepted
alice PUT status=cancelled -> 0 rows changed when guarded by version 1 instead
Alice's write is stale by two commits, and the hash-based guard accepts it. The version-based guard rejects it, because version 1 no longer exists and never will again. A monotonically increasing counter is unique across all versions of a resource by construction, which is precisely the property the specification asks a strong validator to have.
There is a second, subtler cost to hashing that shows up before any ABA scenario: you have to choose what goes into the hash. Hash the whole row and a non-semantic column — updated_at, a trace id, an internal note — invalidates the precondition even though the business state is unchanged, which converts benign writes into conflicts and, under load, into livelock. Hash only a projection and you have reintroduced the risk by hand-picking which fields matter, and you will get that list wrong eventually, silently, in the direction of not detecting a real conflict. A counter has no projection decision to make.
The fair case for hashes is the read path, and it is strong: content addressing answers "are these bytes the same" exactly and cheaply, and that is the question If-None-Match is asking. Use a hash to validate caches if you like. Do not use it as the precondition for a write, unless you have also added something monotonically increasing — at which point that something is the validator and the hash is decoration.
From the row to the header
The storage layer now produces a version. The transport layer has to turn it into an entity tag, and the shape of that tag is not our choice to improvise. RFC 9110 section 8.8.3 gives the grammar:
ETag = entity-tag
entity-tag = [ weak ] opaque-tag
weak = %s"W/"
opaque-tag = DQUOTE *etagc DQUOTE
Two consequences worth internalising. The double quotes are part of the field value, not formatting — "order-1-v1" and order-1-v1 are different things, and a client that sends the unquoted form has sent an invalid tag. And W/ is defined with %s, which makes it case-sensitive: w/"x" is not a weak tag, it is a malformed one.
The server here derives a strong tag from the version, using the same formula the PHP demo printed:
// entityTag mints a STRONG validator. It is derived from the version, which is
// unique across every representation this resource has ever had - the property
// RFC 9110 section 8.8.1 demands of a strong validator. A hash of the body
// would not have it, because a body can recur.
func entityTag(order Order) string {
return fmt.Sprintf("\"order-%d-v%d\"", order.ID, order.Version)
}
The distinction between the two comparison functions then stops being trivia. Section 8.8.3.2 spells out the four cases, and the first row is the one that matters for writes:
W/"1"versusW/"1"— no match under strong comparison, match under weak.W/"1"versusW/"2"— no match either way.W/"1"versus"1"— no match under strong comparison, match under weak."1"versus"1"— match under both.
Read the first two rows again as an operator, not as a table. If your server marks its validators weak, then If-Match can never be satisfied, and your conditional writes never apply. The mechanism does not degrade gracefully to last-write-wins: it returns 412 for a client that did everything correctly, forever. A framework that derives tags from a modification date (section 8.8.2.2: such a validator is implicitly weak unless it is possible to deduce that it is strong) or that marks everything weak "to be safe" has silently removed its own ability to detect lost updates while appearing to support them.
Evaluating the precondition
Parsing the field correctly is a smaller task than it looks, and it is where a naive implementation fails on inputs a real client will send. The field is a list, and a comma is legal inside a tag:
// splitEntityTags splits an entity-tag list into its members, ignoring the
// commas that sit inside a quoted tag. RFC 9110 allows any VCHAR except DQUOTE
// inside an opaque tag, and "," is a VCHAR: strings.Split would shred such a
// tag into two members that mean something the client never sent.
//
// Empty members are dropped, which RFC 9110 section 5.6.1.2 requires a
// recipient to tolerate.
func splitEntityTags(value string) []string {
var (
tags []string
current strings.Builder
inQuotes bool
)
flush := func() {
if tag := strings.TrimSpace(current.String()); tag != "" {
tags = append(tags, tag)
}
current.Reset()
}
for i := 0; i < len(value); i++ {
switch c := value[i]; {
case c == '"':
inQuotes = !inQuotes
current.WriteByte(c)
case c == ',' && !inQuotes:
flush()
default:
current.WriteByte(c)
}
}
flush()
return tags
}
With the members extracted, the two comparison functions are deliberately not interchangeable, and the naming keeps an implementer from reaching for the wrong one:
// strongCompare is the comparison function from RFC 9110 section 8.8.3.2: two
// entity tags are equivalent only if BOTH are strong and their opaque tags
// match character for character. A weak tag never matches, on either side.
func strongCompare(a, b string) bool {
if strings.HasPrefix(a, "W/") || strings.HasPrefix(b, "W/") {
return false
}
return a == b
}
// weakCompare is the comparison function If-None-Match uses: the weakness
// indicator is ignored. Cache validation can tolerate a validator that did not
// change for every byte change. A precondition cannot.
func weakCompare(a, b string) bool {
return strings.TrimPrefix(a, "W/") == strings.TrimPrefix(b, "W/")
}
Evaluating If-Match then reports two things rather than one, because "the field was absent" and "the field was present and false" are different situations with different answers:
// evaluateIfMatch implements RFC 9110 section 13.1.1. It reports whether the
// field was present at all, so the caller can answer 428 when it insists on
// conditional writes, and whether the condition holds.
func evaluateIfMatch(header http.Header, current string) (present, satisfied bool) {
// Repeated field lines combine into one comma-separated list, so every
// line has to be considered - not just the first one.
values := header.Values("If-Match")
if len(values) == 0 {
return false, false
}
for _, value := range values {
for _, tag := range splitEntityTags(value) {
if tag == "*" {
return true, true // any current representation satisfies it
}
if strongCompare(tag, current) {
return true, true
}
}
}
return true, false
}
The absent case deserves a policy, not a default. HTTP does not require conditional writes; an unconditional PUT is perfectly legal, and applying it is exactly what RFC 6585 section 3 created 428 Precondition Required for. The server below refuses unconditionally: no If-Match, no write, and the response hands back the current validator so the client's retry can be conditional. That choice is defensible on the grounds that a client which forgets the header is indistinguishable from a client that will cause a lost update, and the second one is not worth the first one's convenience.
One conformance nuance to carry into your client. A false precondition does not oblige the origin server to answer 412. Section 13.1.1 permits a success response instead: if the request is a state-changing operation that appears to have already been applied to the selected representation, the origin server MAY respond with a 2xx (Successful) status code. That is the correct answer when the client is retrying a request whose earlier response was lost, and it is why a client must treat 412 as the expected-but-not-guaranteed outcome, not as the only signal that its write did not apply.
The handler, end to end
The handler keeps the two uses of the tag separate, which is the shortest way to see that they are not the same mechanism:
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request, id int) {
order, ok := s.store.Get(id)
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
tag := entityTag(order)
w.Header().Set("ETag", tag)
// The other half of the ETag story. If-None-Match is a cache validator, so
// RFC 9110 section 13.1.2 evaluates it with the WEAK comparison function:
// a cache only needs to know whether this is the representation it holds.
if evaluatesToNotModified(r.Header, tag) {
w.WriteHeader(http.StatusNotModified)
return
}
writeJSON(w, http.StatusOK, order)
}
func (s *Server) handlePut(w http.ResponseWriter, r *http.Request, id int) {
current, ok := s.store.Get(id)
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
present, satisfied := evaluateIfMatch(r.Header, entityTag(current))
switch {
case !present && s.requireIfMatch:
// Refuse to guess rather than overwrite. The client gets the current
// validator back so its retry can be conditional.
w.Header().Set("ETag", entityTag(current))
http.Error(w, "conditional request required: send If-Match", http.StatusPreconditionRequired)
return
case present && !satisfied:
// RFC 9110 section 13.2.2, step 1: If-Match is evaluated first, and a
// false condition means the method is not performed.
w.Header().Set("ETag", entityTag(current))
http.Error(w, "precondition failed", http.StatusPreconditionFailed)
return
}
var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
http.Error(w, "body must be {\"status\": \"...\"}", http.StatusBadRequest)
return
}
if body.Status == "" {
http.Error(w, "status must not be empty", http.StatusUnprocessableEntity)
return
}
updated, err := s.store.Update(id, current.Version, body.Status)
if err != nil {
// The row moved between the precondition check and the write. This is
// the same closing window the SQL statement closes with
// rowCount()==0, and it produces the same answer to the client.
w.Header().Set("ETag", entityTag(updated))
http.Error(w, "precondition failed", http.StatusPreconditionFailed)
return
}
w.Header().Set("ETag", entityTag(updated))
w.WriteHeader(http.StatusNoContent)
}
The ordering follows section 13.2.2, which fixes the sequence when a request carries several preconditions: when recipient is the origin server and If-Match is present, evaluate the If-Match precondition — if false, respond 412. Lost-update preconditions are evaluated before cache validators, which is the specification agreeing with the intuition that data integrity outranks bandwidth.
Two returns carry an ETag, and that is the part that makes the pattern usable. A 412 response that does not tell the client what the current validator is forces a re-read before the retry — one extra round trip precisely when the system is contended, multiplied by every retrying client. Handing back the fresh tag turns "conflict" into "retry with this", which is what the wire transcript below shows happening in a single step.
That behaviour rests on a contract the store has to keep, and it is worth stating because the idiomatic Go answer is the opposite. Update returns the row as it now stands even when the precondition fails — (current, errPrecondition), not the zero Order{}. A store that returned the zero value on error would make the handler emit ETag: "order-0-v0", and every rejected client would then retry with a validator that matches nothing. The stale write would still be refused, which sounds fine until you notice the correct one is refused too, permanently. The test harness asserts this contract rather than trusting it — at the store level, because the window that reaches it (the version moving between the handler's read and its write) is not something an external request can trigger on demand.
Watching the race happen
The mechanism can be described until it sounds obviously correct. The reason to write the test is that "obviously correct" is exactly how the last-write-wins version got shipped. Two goroutines, one sync.WaitGroup, and a channel used as a starting gun so both requests leave at the same moment:
// Both writers read the resource first, so both hold the same validator.
// That is the entire premise of the lost update.
etag, _, err := getOrder(server.URL)
const writers = 2
statuses := [writers]string{"paid", "shipped"}
codes := make([]int, writers)
bodies := make([]string, writers)
errs := make([]error, writers)
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start // release both writers as close to simultaneously as possible
// Both requests carry the SAME validator, taken from the same read.
codes[i], bodies[i], errs[i] = putOrder(server.URL, etag, statuses[i])
}(i)
}
close(start)
wg.Wait()
winners, losers := 0, 0
for _, code := range codes {
switch code {
case http.StatusNoContent:
winners++
case http.StatusPreconditionFailed:
losers++
}
}
if winners != 1 || losers != 1 {
t.Fatalf("want exactly one winner (204) and one loser (412), got %v (%v)", codes, bodies)
}
Run under the race detector, the test reports the outcome it is asserting:
=== RUN TestConcurrentWritersExactlyOneWins
handler_test.go:134: both writers sent If-Match: "order-1-v1"
handler_test.go:135: writer 0 -> 204
handler_test.go:136: writer 1 -> 412 precondition failed
handler_test.go:137: codes [204 412]: one 204 No Content, one 412 Precondition Failed
handler_test.go:138: resource afterwards: "order-1-v2" {"id":1,"status":"paid","version":2}
--- PASS: TestConcurrentWritersExactlyOneWins (0.00s)
The assertion is deterministic; the winner is not. Repeated twelve times, writer 0 won five times and writer 1 won seven — the scheduler decides who gets there first, and the mechanism is indifferent. What it enforces is the invariant: exactly one write applies, the version advances exactly once, and the other client is told. A test that asserted which goroutine wins would be flaky and would also be testing the wrong property.
The same sequence over a real socket, with real headers, reads like this — one request per line, produced by a script that starts the server, drives the race, and shuts it down:
### 1. Read the resource once - this validator is what both clients will hold
GET /orders/1 -> 200 ETag: "order-1-v1" {"id":1,"status":"new","version":1}
### 2. The same header, the other half of its job: cache validation
GET /orders/1 If-None-Match: "order-1-v1" -> 304 ETag: "order-1-v1"
GET /orders/1 If-None-Match: W/"order-1-v1" -> 304 ETag: "order-1-v1"
### 3. A write that carries no precondition at all
PUT /orders/1 (no If-Match) -> 428 ETag: "order-1-v1" conditional request required: send If-Match
### 4. The race: both clients released together, both holding the same validator
PUT /orders/1 If-Match: "order-1-v1" paid -> 204 ETag: "order-1-v2"
PUT /orders/1 If-Match: "order-1-v1" shipped -> 412 ETag: "order-1-v2" precondition failed
### 5. The resource afterwards: written once, version advanced once
GET /orders/1 -> 200 ETag: "order-1-v2" {"id":1,"status":"paid","version":2}
### 6. The rejected client retries with the validator it was handed
PUT /orders/1 If-Match: "order-1-v2" shipped -> 204 ETag: "order-1-v3"
Step 2 and step 4 in the same transcript are the cleanest statement of why these are two mechanisms: W/"order-1-v1" is a valid cache validator and an invalid precondition. It earns a 304 on the read path and would earn a permanent 412 on the write path. Step 6 is the payoff of returning the fresh tag on failure — the losing client does not re-read anything, it retries with the tag it was just given and succeeds.
What this does not solve
Optimistic concurrency control is not a safety net around the whole system. It is a precise mechanism with a list of things it does not do, and being explicit about them is what separates it from optimism.
- It does not retry for you. A
412that the client logs and drops is a lost update with extra ceremony. The pattern only pays off if every writer implements reload-and-reapply or surfaces the conflict; a codebase where one background job ignores the status code has the same data integrity as no ETags at all. - Retries can livelock. Under real contention, two writers retrying in lockstep conflict repeatedly. Retries need jittered backoff and a bounded count, and a conflict rate that stays high is a signal that the access pattern wants a queue or a pessimistic lock, not more optimism.
- It protects one write, not an invariant. A single statement is atomic; a rule that spans rows — "the sum of these three rows must be zero" — is not made safe by versioning each row. You need a transaction with an appropriate isolation level, and you need to know what the CAS does and does not cover.
- High contention is where it degenerates. If ten writers always want the same hot row, optimistic control converts a serialization problem into a retry storm. Contention is the reason both patterns still exist.
- The user experience of a conflict is a product decision. Two browser tabs both showing stale state produce a
412, and the honest resolutions — reload and lose my edit, or merge, or let me diff — are all worse than never having conflicted. The server can detect the conflict; it cannot decide what the person should be told. - It only covers writers that send the header. This is the one that bites hardest in production, because the writers that omit it are internal: a migration, an admin script, a queue consumer written before the API was conditional. The guard is opt-in per request, and one unguarded writer is enough to reintroduce exactly the overwrite you were preventing.
The remedy for most of these is organisational rather than technical: make the precondition something the client library adds by default, so a developer has to opt out of safety instead of remembering to opt in.
Where the guarantee goes to die
Assembled, the guarantee is a conjunction. A strong validator derived from something monotonic, a compare-and-set inside the store that owns the row, a client that sends it, and a server that enforces it. Each of the four is load-bearing, and the interesting question is what happens when a layer you do not control sits between the client and the store, mediating all four.
The API survives that hop, and the guarantee does not — because the API is a shape and the guarantee is a property of the things underneath. A portable interface can promise that PUT means PUT everywhere. It cannot promise that every implementation of PUT can honour a precondition, because the precondition requires something specific of the storage engine: a compare-and-set to translate to. Where the engine has no such operation, or the component never mapped the one it has onto the API's validator, the conditional write has to degrade — and the shape of the degradation is the whole story.
Two forms are worth naming, because both are reachable in production and neither is exotic. First, the precondition can be dropped: the storage engine has a compare-and-set in its own protocol, but the component that adapts it to the API does not expose it, so a conditional write becomes an unconditional one and the API returns success. The call is conditional, the effect is not, and nothing in the response says so. Second, the outcome can be renamed: the precondition fails correctly, but the failure surfaces in the abstraction's own vocabulary rather than as 412, so client code written against HTTP semantics — the code you just wrote in this article — stops recognising the condition it was built to handle.
There is a third failure that is not about the store at all but about defaults. If the middleware treats the validator as optional and skips the check when a request omits it, then the guarantee holds only for the most disciplined caller. That is the same opt-in weakness from the previous section, except now the guard is per-request inside a system you do not read source for, and the assumption that it is on is an assumption about other people's code.
None of this makes the abstraction wrong. It makes the abstraction a place where properties get their final value, and the honest reading of a portable API is that it is portable as a vocabulary — not as a promise about behaviour. Whether a conditional write is actually conditional underneath is a question with a testable answer, and the answer differs between two stores the project lists identically as Stable.
That is what the companion piece measures, with a running demo and the exact responses from each store: Dapr: A Portable API Over Non-Portable Guarantees. The mechanism in this article is the thing being abstracted; that article is about what reaches the other side.
Summary
An ETag is a cache validator on reads and a concurrency guard on writes, and the specification imposes different comparison rules on the two uses: If-None-Match takes weak comparison, If-Match takes strong. A server that marks its validators weak has not weakened its concurrency control, it has removed it, and the failure looks like a permanent 412 for correct clients.
The optimistic mechanism is a version column that the database increments, a compare-and-set whose predicate is that version, and a strong validator derived from it. It cannot be a hash of the content if the content can recur, it cannot be a counter incremented in application memory, and it cannot be a check performed in application code outside the write. It costs a column, a retry path, and the discipline to send the header from every writer — and it buys a failure you can see instead of a success that quietly deleted someone's change.
The code in this article is runnable and was run: PHP 8.5 against SQLite, Go 1.27 with -race, eight tests, and a wire-level transcript that ends with the losing client retrying successfully with the validator it was handed. When a layer is later inserted between the client and the store, that is the behaviour whose fate you are deciding.