Skip to content

// Data & Databases · 09

Optimistic Concurrency

Two users edit one recordleads toSecond save is rejected

Detect conflicting edits at save time instead of locking up front.

How it fits together

Optimistic Concurrency structure«client»User Areads version 7UPDATE WHERE v = 7«client»User Breads version 7UPDATE WHERE v = 7Order rowversion 7 to 8412 Preconditioncurrent state returned1 v7 to v82 WHERE v = 73 0 rows
createsuses / callsfails / error path· numbered steps replay in order
  • User A creates Order row (1 v7 to v8)
  • User B uses / calls Order row (2 WHERE v = 7)
  • Order row fails / error path 412 Precondition (3 0 rows)

Spot it

  • One user's save silently overwrites another's
  • Edits happen in long-lived forms
  • Conflicts are rare but costly when they happen

It costs

No locks held, but callers must handle rejected saves.

In code

public sealed class Order{    public Guid Id { get; init; }    public string Status { get; set; } = "";    [ConcurrencyCheck] public int Version { get; set; }}​public sealed class OrderHandler(AppDbContext db){    public async Task<IResult> Update(Guid id, int ifMatch, string status, CancellationToken ct)    {        var order = await db.Orders.FindAsync([id], ct) ?? throw new KeyNotFoundException();        var entry = db.Entry(order);        entry.Property(o => o.Version).OriginalValue = ifMatch;        order.Status = status;        order.Version = ifMatch + 1;        try        {            await db.SaveChangesAsync(ct);            return Results.Ok(order);        }        catch (DbUpdateConcurrencyException)        {            await entry.ReloadAsync(ct);            return Results.Json(order, statusCode: StatusCodes.Status412PreconditionFailed);        }    }}
Go deeperThe full explanation and what to ask for · 1 min readExpand

Each row carries a version. An update says WHERE id = @id AND version = @expected; if no row changes, someone else got there first. Nothing is locked while the user is thinking, which is why it suits web forms and APIs.

SQL Server has a row version column, bumped automatically on every update. PostgreSQL has the xmin system column, which many ORMs can use as a concurrency token, or you can increment an integer version yourself. Over HTTP the version travels as an ETag and comes back in If-Match.

The classic mistake is catching the conflict and retrying the same write, which just overwrites again. Re-read, re-apply the intent, or ask the user. When conflicts are frequent, pessimistic locking (SELECT ... FOR UPDATE, or an update lock hint) or a single atomic UPDATE beats endless retries.

Ask for it

  • lost update
  • concurrency token
  • version column
  • ETag / If-Match
  • conflict exception

Add optimistic concurrency to Order: a version column (a row version column on SQL Server, xmin on PostgreSQL) as the concurrency token, returned as an ETag. Require If-Match on PUT and map the conflict error to 412 with the current state.