Optimistic Concurrency Control Rejects Stale Writes

Two clients can read the same record, make different edits, and save seconds apart. If each update blindly replaces the stored value, the later write can erase the earlier one even though both requests succeeded.

Optimistic concurrency control prevents that silent overwrite by attaching a condition to the write. The client records a version when it reads the data. Its update succeeds only if that version is still current. A changed version turns the write into a conflict instead of an unnoticed loss.

The race starts with a valid read

Consider an account record at version 17. Client A and client B both read it before either submits an update.

database: balance_limit=5000, version=17

client A reads version 17
client B reads version 17

client A sets balance_limit=6000
client B sets balance_limit=7000

Without a concurrency condition, both writes can be valid SQL operations. If B commits last, the database ends at 7000 and A’s accepted change disappears.

The defect is not simultaneous execution in the narrow sense. The requests can be separated by seconds or minutes. The critical fact is that B’s decision was based on state that became stale before B wrote it.

A version turns freshness into a write predicate

A common implementation stores an integer version beside the mutable fields.

UPDATE accounts
SET balance_limit = 6000,
    version = version + 1
WHERE id = 42
  AND version = 17;

If the row is still at version 17, exactly one row is updated and the version becomes 18. A later update carrying version 17 matches no row.

A: WHERE version = 17 -> 1 row changed -> version 18
B: WHERE version = 17 -> 0 rows changed -> conflict

The zero-row result is meaningful application state, not merely a database oddity. The service must distinguish a missing record from a version conflict when its API contract treats them differently.

Some databases and ORMs expose this pattern through compare-and-swap, row versions, revision fields, or optimistic locking features. The names vary, but the invariant is the same: the write is conditional on state observed earlier.

The check and mutation must be atomic

Reading the current version in one statement and updating in a later unconditional statement leaves another race.

SELECT version ... -> 17
another writer commits version 18
UPDATE ...          -> overwrites newer state

The expected version belongs in the mutation predicate, or the datastore must provide an equivalent atomic conditional-write primitive. The validation and mutation need one indivisible decision from the perspective of competing writers.

A transaction can also provide the required protection when its isolation and locking semantics are suitable, but merely placing separate read and write statements inside a transaction does not automatically produce optimistic concurrency behavior.

Timestamps are usually weaker version tokens

A modification timestamp can act as a token only if its precision, generation rules, and comparison semantics make collisions impossible for the required workload. That assumption is easy to violate.

Integer revisions avoid clock synchronization and timestamp precision concerns. Opaque entity tags or datastore-generated revision values can work equally well when every relevant mutation changes the token.

The token does not need to reveal ordering to the client. It needs to change whenever state covered by the concurrency contract changes.

Conflict is a normal outcome, not a retry command

A version conflict says that the precondition for the proposed write is no longer true. Blindly retrying the same replacement against the new version can recreate the lost-update bug at the application layer.

For a user-edited document, the service may return a conflict and let the client fetch current state, compare edits, and submit a new decision. An HTTP API can express a conditional mutation with an entity tag and If-Match, returning 412 Precondition Failed when the supplied validator is stale.

For machine-generated operations, a retry may be safe if the operation can be recomputed from fresh state. An increment, for example, can read the new value and derive a new candidate. A replacement based on a human decision usually needs more care.

Version scope must match the consistency boundary

One version for an entire row means unrelated fields can conflict. Client A may edit a display name while client B changes a notification setting; a shared row version can reject one even if the changes do not overlap.

That conservatism is often acceptable because it keeps the rule simple. Systems with high contention can use narrower aggregates, field-specific merge rules, or operations that encode intent rather than replacement.

The opposite error is more dangerous: a version that changes for only some mutations can allow a writer to pass its check after relevant state has changed. Every mutation inside the protected consistency boundary must advance or replace the token.

Partial updates still need a stale-write policy

PATCH does not remove the concurrency question. A partial update can still depend on an earlier representation.

If a request means “set status to approved based on the record I reviewed,” a stale version matters even when only status is transmitted. If a request means “set this independent preference to true regardless of other fields,” the service may intentionally use a narrower condition.

The API should encode that distinction rather than infer safety from the number of fields in the payload.

Multi-record invariants need a larger mechanism

A per-row version protects one row from stale replacement. It does not by itself protect an invariant spanning several rows.

Suppose two transactions each inspect separate capacity records and then reserve resources. Conditional updates on individual rows may all succeed while the combined result violates a global limit. Such rules may require a transaction, serialized coordinator, constraint, reservation protocol, or another mechanism whose scope covers the invariant.

Optimistic concurrency control is precise when the protected aggregate is precise. Expanding its claims beyond that boundary creates false confidence.

Metrics should separate contention from faults

Conflicts are expected when multiple writers legitimately race. They should be observable without being classified automatically as server failures.

Useful signals include conflict rate by operation, attempts per successful mutation, time between read and rejected write, and records or aggregate types with concentrated contention. A rising conflict rate can indicate a newly hot resource, an overly broad version scope, or a client holding editable state for longer than expected.

Metrics should avoid unbounded record identifiers as labels. Traces or controlled logs are better places for individual identifiers when diagnostics require them.

Conditional writes make stale state visible

Optimistic concurrency control does not stop clients from reading old state. It stops an old observation from silently authorizing a newer overwrite.

That boundary makes the mechanism useful when conflicts are possible but holding locks across user think time, network calls, or distributed request paths would be impractical. Writers proceed independently until the datastore evaluates the condition at mutation time.

The essential contract is compact: carry a token from the read, include it in an atomic conditional write, treat a mismatch as a first-class conflict, and retry only after the operation has been reconsidered against current state.