DEV Community

Anton Martyniuk
Anton Martyniuk

Posted on Originally published at antondevtips.com

Optimistic vs Pessimistic Concurrency in .NET

Almost everyone had this situation in production: a customer changed the delivery address on a shipment, saved it, saw the confirmation, and an hour later the old address is back.

You start investigating, and you don't see any errors or exceptions in the logs.
The request returned 200 OK, but the data is just wrong.

This is a lost update, and it's one of the most common bugs in production .NET systems.
It happens whenever two requests read the same row, change it in memory, and write it back.

I've debugged this exact bug in payment systems, warehouse software, and booking flows.
It never shows up in a unit or integration test, because the tests don't run two requests in the same millisecond (your integration test can actually test concurrent requests).

There are two classic ways to solve it: optimistic concurrency and pessimistic concurrency.
They solve the same problem with different assumptions, and picking the wrong one either kills your throughput or leaves the bug in place.

In this post, we will explore:

  • The Lost Update Problem
  • Optimistic Concurrency: Detect the Conflict
  • Handling DbUpdateConcurrencyException
  • Retrying Conflicts with a Resilience Pipeline
  • Pessimistic Concurrency: Lock the Row First
  • A Third Option: One Atomic Statement
  • Optimistic vs Pessimistic: Deep Comparison
  • How to Choose: A Decision Checklist

Let's dive in.


👉 Read original article on my newsletter: https://antondevtips.com/blog/optimistic-vs-pessimistic-concurrency-in-dot-net

The Lost Update Problem

Here is the domain we'll use throughout the post: a shipment that a warehouse operator can edit.

public class Shipment
{
    public Guid Id { get; set; }
    public string Number { get; set; }
    public string Address { get; set; }
    public string Carrier { get; set; }
    public ShipmentStatus Status { get; set; }
    public List<ShipmentItem> Items { get; set; } = [];
}
Enter fullscreen mode Exit fullscreen mode

And here is the update handler almost everyone writes first:

public async Task<Result> Handle(UpdateShipmentRequest request, CancellationToken ct)
{
    var shipment = await dbContext.Shipments
        .FirstOrDefaultAsync(s => s.Number == request.Number, ct);

    if (shipment is null)
    {
        return Result.NotFound($"Shipment '{request.Number}' not found");
    }

    shipment.Address = request.Address;
    shipment.Carrier = request.Carrier;

    await dbContext.SaveChangesAsync(ct);
    return Result.Success();
}
Enter fullscreen mode Exit fullscreen mode

The code reads a row, changes it in memory, and writes it back.

Between the read and the write there is a gap. It's small, usually a few milliseconds, but it's real. Anything that happens inside that gap is invisible to this handler.

Now put two operators in that gap at the same time. One changes the address, the other changes the carrier:

Both requests got a success response, and both updated exactly one row. Neither of them did anything wrong on its own.

But the row ends up with the address "Amsterdam" and the carrier "UPS". Request A's address change is gone, and no one was notified.

This is what a lost update means: one writer silently overwrites another writer's change because it never saw it.

The first instinct is to wrap the handler in a transaction, and that doesn't fix it.

At the Read Committed isolation level, which is the default in PostgreSQL and SQL Server, both transactions read a valid committed row, and both writes succeed.
The database is doing exactly what you asked. You just never told it that the second write depended on the first read.

Serializable isolation does catch this, at the cost of serialization failures that you have to retry anyway.
If you want the full picture of what each level protects against, I covered it in Complete Guide to Transaction Isolation Levels in SQL.

The two techniques below fix the problem directly, and you can apply either one per use case.

Optimistic Concurrency: Detect the Conflict

Optimistic concurrency starts with the assumption that conflicts are rare.

So it doesn't lock anything. It lets both requests run at full speed and, at the moment of writing, makes the database check whether anyone changed the row in the meantime.

The mechanism is a concurrency token: a column whose value changes on every update. You read it together with the row, and your UPDATE statement carries it in the WHERE clause.

If the token in the database no longer matches the one you read, your UPDATE matches zero rows, and you know somebody got there first.

EF Core supports this out of the box, and you have three ways to configure it.

PostgreSQL, using the built-in xmin system column:

modelBuilder.Entity<Shipment>()
    .UseXminAsConcurrencyToken();
Enter fullscreen mode Exit fullscreen mode

This is the cheapest option on PostgreSQL because xmin already exists on every row. You get concurrency checks without adding a column or writing a migration.

SQL Server, using a rowversion column:

modelBuilder.Entity<Shipment>()
    .Property<byte[]>("Version")
    .IsRowVersion();
Enter fullscreen mode Exit fullscreen mode

SQL Server maintains the value itself on every update, so you never assign it in code.

Any provider, using your own token:

public class Shipment
{
    // ...
    public Guid Version { get; set; }
}

modelBuilder.Entity<Shipment>()
    .Property(s => s.Version)
    .IsConcurrencyToken();
Enter fullscreen mode Exit fullscreen mode

A manual token is the one I reach for most often, and not because of provider portability. It's a plain Guid column, so it survives a round trip to a browser or a mobile client, which is exactly what a stateless web API needs.

You do have to change the value yourself. The cleanest place is a SaveChangesAsync override on the DbContext:

public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
    var entries = ChangeTracker.Entries<Shipment>()
        .Where(e => e.State is EntityState.Added or EntityState.Modified);

    foreach (var entry in entries)
    {
        entry.Entity.Version = Guid.NewGuid();
    }

    return base.SaveChangesAsync(ct);
}
Enter fullscreen mode Exit fullscreen mode

Whichever option you pick, EF Core now generates a different UPDATE:

UPDATE shipments
SET address = @p0, carrier = @p1, version = @p2
WHERE id = @p3 AND version = @p4;
Enter fullscreen mode Exit fullscreen mode

The version = @p4 predicate is the whole trick. EF Core sends the value it read, counts how many rows the statement actually changed, and throws a DbUpdateConcurrencyException when the answer is zero.

Here is the same race as before, with the token in place:

Request A still wins the race, exactly as it did before. The difference is that Request B now finds out rather than quietly replacing the value.

There's one part that's easy to miss in a web API. Your two requests don't share a DbContext, and they don't even overlap in time. The operator opens an edit form, thinks for two minutes, and submits.

For the check to mean anything, the version has to travel to the client and come back:

public sealed record ShipmentResponse(
    string Number,
    string Address,
    string Carrier,
    string Version);

public sealed record UpdateShipmentRequest(
    string Number,
    string Address,
    string Carrier,
    string Version);
Enter fullscreen mode Exit fullscreen mode

Then you tell EF Core to use the client's version instead of the one you just read from the database:

dbContext.Entry(shipment)
    .Property(s => s.Version)
    .OriginalValue = Guid.Parse(request.Version);

shipment.Address = request.Address;
shipment.Carrier = request.Carrier;

await dbContext.SaveChangesAsync(ct);
Enter fullscreen mode Exit fullscreen mode

Setting OriginalValue is what puts the client's version into the WHERE clause. Without this line, you're comparing the row against a value you read milliseconds ago, and the two minutes when the operator was typing go completely unchecked.

Now the write fails when it should. The next question is what to do about it.


👉 Read original article on my newsletter: https://antondevtips.com/blog/optimistic-vs-pessimistic-concurrency-in-dot-net

Top comments (1)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw

One production rule I’d add: DbUpdateConcurrencyException is not automatically retryable. If a command expresses user intent—editing an address or approving an order—a blind reload and retry can silently replace the competing decision. Return a conflict containing both versions or merge explicitly; reserve automatic retries for operations proven idempotent or commutative. I’d also measure conflict rate, because a “rare contention” assumption becoming false is the signal to consider an atomic statement or tightly scoped pessimistic lock.