Optimistic Concurrency Rejects Stale Writes Before They Replace Newer State

A read-modify-write flow looks harmless when only one actor touches a record. A client reads state, changes part of it, then writes the result back. With concurrent actors, the interval between the read and the write becomes a race. Another writer can commit a newer value during that interval, and an unconditional update can erase it.

Optimistic concurrency control puts a condition on the final write. The client carries a version derived from the state it read, and the storage layer accepts the mutation only if that version is still current. A mismatch becomes a conflict rather than a silent overwrite.

The technique is called optimistic because readers do not reserve the record in advance. Contention is detected at commit time. That trade is attractive when conflicts are uncommon, read paths should remain cheap, or holding locks across application and network boundaries would be impractical.

The lost-update window sits between read and write

Consider a profile row with version = 41. Two clients read it at nearly the same time.

Client A changes the display name. Client B changes the timezone. If both clients later send complete row representations and the database accepts both updates without a condition, the second write can replace fields produced by the first.

The sequence can look like this:

A reads: name=Rina, timezone=UTC, version=41
B reads: name=Rina, timezone=UTC, version=41

A writes: name=Rina S, timezone=UTC
B writes: name=Rina,   timezone=Asia/Jakarta

If B commits last, the display-name change can disappear even though both writes individually succeeded.

The problem is not that either client wrote malformed data. Each client acted on a snapshot that was valid when read. The missing element is a check that the snapshot is still the current basis for replacement.

A version predicate makes the race visible

A common database form adds a version column and includes it in the update predicate:

UPDATE profiles
SET
    display_name = :display_name,
    timezone = :timezone,
    version = version + 1
WHERE id = :id
  AND version = :expected_version;

A client that read version 41 submits expected_version = 41. If no competing update has committed, one row matches and the update advances the version to 42.

If another writer already moved the row to version 42, the predicate matches zero rows. That zero-row result is not an ordinary success. It is the concurrency signal: the caller’s proposed replacement was based on stale state.

The check and mutation must be one atomic storage operation. Reading the version in one query and issuing an unconditional update in a later query recreates the race between those two statements.

Compare-and-swap is the core shape

The version-column pattern is an application-level form of compare-and-swap:

replace current value with proposed value
only if current version equals expected version

The comparison token does not have to be an integer. A database can use a revision identifier, an immutable row token, or another value that changes whenever relevant state changes. The key property is that the token used for comparison identifies the state on which the client based its mutation.

Monotonic integers are convenient because they are compact and easy to inspect. They should still be treated as concurrency tokens rather than wall-clock time. A version of 42 says that it differs from version 41; it does not say when the change occurred.

Timestamps can serve as tokens only when their generation and comparison semantics make collisions impossible for the required scope. Coarse timestamps are risky if multiple commits can receive the same visible value.

HTTP exposes the same contract with validators

The same pattern can cross an HTTP boundary. A server may return an ETag with a representation:

ETag: "profile-42"

A client can later send a conditional mutation:

If-Match: "profile-42"

The server applies the mutation only if the selected representation still matches that validator. If it does not, the precondition fails rather than allowing a stale replacement.

For state-changing requests, this contract is useful because the concurrency condition remains explicit across the network. The application does not need to keep a database transaction or mutex open while a person edits a form for several minutes.

An ETag used for this purpose must correspond to the representation semantics protected by the mutation. A validator tied only to an unrelated cache artifact can be a poor concurrency token if it does not change when protected state changes.

Conflict handling is part of the product contract

Detecting a stale write is only half of the design. The caller also needs a policy for the conflict.

For machine-generated operations that are safe to recompute, the client may fetch fresh state, reapply its intended transformation, and attempt a new conditional write. This is appropriate only when the operation has clear replay semantics.

For interactive editing, automatic replay can hide a meaningful conflict. If two people changed the same paragraph, field, or policy value, silently choosing one edit may be worse than presenting current state and asking for a deliberate resolution.

A conflict response should therefore preserve enough context for the caller to act. At minimum, the caller needs a distinct conflict outcome. Depending on the API, returning the current version or a route for fetching it can reduce an extra ambiguous failure path.

Retries also need a bound. A hot record can keep changing between each refresh and retry. Unlimited retry loops convert contention into extra load and can starve a caller that never catches a quiet interval.

Partial updates reduce collisions but do not remove concurrency

Patch-style APIs can reduce accidental replacement because a client sends only the fields it intends to change. If A updates display_name and B updates timezone, a storage layer that performs independent field updates may preserve both changes.

That does not eliminate every conflict. Two clients can still modify the same field. More subtly, an invariant can span multiple fields even when each request touches a different subset.

Suppose a scheduling record has start_at and end_at, with the invariant that the start precedes the end. Independent patches based on stale snapshots can produce a combination that neither client intended.

Field granularity and concurrency policy are separate choices. Narrow mutations reduce the surface area of replacement; version checks establish whether a mutation is permitted against the state that currently exists.

Version scope should match the protected invariant

A single row version is simple, but it can create false conflicts when independent fields change frequently. Splitting state into separate resources or aggregates can reduce contention if those pieces truly have independent invariants.

The reverse mistake is more serious: using separate version tokens for values that must change consistently. If an invariant spans several rows, checking one row’s version may not protect the invariant.

At that point, the design may need a transaction with predicates covering all relevant rows, a serializable isolation level, a constraint enforced by the database, or a different aggregate boundary. Optimistic concurrency is a mechanism for detecting stale assumptions; it is not a substitute for every form of transactional consistency.

The concurrency token should cover the state whose freshness matters to the proposed operation.

Side effects need ordering around the conditional commit

A conditional database update can fail. External side effects performed before that update cannot always be taken back.

Consider code that sends a message, charges an external account, or publishes an event before attempting the versioned write. If the write then reports a conflict, the external action may already be visible even though the state transition was rejected.

The safer ordering depends on the operation, but the conditional commit must be considered together with side-effect delivery. Patterns such as a transactional outbox can couple a committed state transition with later event publication. Idempotency controls can protect retries of external operations where the remote interface supports them.

The version predicate protects the target state from stale replacement. It does not automatically make surrounding side effects atomic.

Metrics should separate contention from storage failure

A version conflict is an expected concurrency outcome, not necessarily a database fault. Operational telemetry should distinguish it from timeouts, connection failures, constraint violations, and internal errors.

Useful signals include conflict rate by resource type, retry count, retry success rate, records with concentrated contention, and latency added by conflict recovery. A sudden rise in conflicts can indicate a new hot key, a client that holds stale state for too long, or an API change that expanded the scope of replacement.

Treating every zero-row conditional update as a generic server error loses that information. Treating every conflict as harmless can also hide a workload that has outgrown the chosen concurrency model.

Tests need an actual interleaving

A sequential test cannot prove that stale writes are rejected. The test should create two operations from the same initial version, commit one, then attempt the other with the original token.

The expected result is precise: the first accepted write advances the token, and the second write does not replace the committed state.

A second test can cover conflict recovery by fetching the new version and applying a permitted retry. Tests for HTTP APIs can perform the same sequence with ETag and If-Match.

These cases are small, but they exercise the boundary that matters. The feature is not the presence of a version column. The feature is the atomic rule that a mutation based on stale state cannot silently replace newer state.

Optimistic concurrency works best when that rule remains visible from storage to API behavior. A token travels with the state, the mutation names the token it expects, and a mismatch has explicit semantics. That turns a timing-dependent overwrite into a conflict the system can handle deliberately.