Implementing Concurrency Control in ASP .NET Core (Step-by-Step)

Introduction
Here is the bug, and it does not throw. Two requests hit your API at the same moment to adjust the same bank account. Both read a balance of 100. One adds 50 and saves. The other subtracts 20 and saves. The final balance is 80, and the +50 is just gone. No exception, no log line, nothing to page you. This is the lost update problem, and any endpoint that does read-modify-write on shared data has it.
This video fixes it with optimistic concurrency control in EF Core: add a version column, let the database reject a write built on stale data, catch the resulting exception, and retry with fresh data. The demo is a deliberately race-prone bank account balance endpoint.
🎬 Watch the full video here:
Optimistic vs pessimistic
Pessimistic concurrency locks the row when you read it, so nobody else can touch it until you are done. It is simple to reason about but it serializes access, holds database locks across your business logic, and scales badly under contention.
Optimistic concurrency assumes conflicts are rare. Everyone reads freely. At write time, the database checks whether the row changed since you read it. If it did, your write is rejected and you deal with it. No locks held during your logic, much better throughput - at the cost of having to handle the rejection.
For a typical web API, optimistic is almost always the right call.
The rowversion column
EF Core implements optimistic concurrency with a concurrency token - a column it includes in the WHERE clause of every UPDATE and DELETE. SQL Server's rowversion (also called timestamp) is the natural fit: an 8-byte value the database bumps automatically on every change to the row.
public abstract class BaseEntity
{
public Guid Id { get; set; }
public byte[] RowVersion { get; set; }
}
modelBuilder.Entity<BankAccount>()
.Property(v => v.RowVersion)
.IsRowVersion();
IsRowVersion() tells EF Core this property is database-generated and is the concurrency token. Putting it on a BaseEntity gives every entity the same protection for free.
What actually happens on save
With the token configured, EF Core generates an UPDATE like:
UPDATE BankAccounts
SET Balance = @newBalance
WHERE Id = @id AND RowVersion = @originalRowVersion
Then it checks the affected row count. If another transaction updated the row between your read and your write, the RowVersion no longer matches, zero rows are affected, and EF Core throws DbUpdateConcurrencyException. That exception is the entire mechanism - it is EF Core telling you "the data you based this change on is stale".
Handling the conflict
Catching the exception is not enough - you have to decide what to do. Three options:
- Client wins: overwrite whatever is there with your values. Fine for last-write-wins fields, dangerous for a balance.
- Database wins: discard your change, tell the user to reload.
- Merge / retry: reload the current values, re-apply your intended change on top, try again.
For a balance adjustment ("add 50"), retry is correct, because the intent is relative. The demo reloads the conflicted entry and re-runs the operation:
catch (DbUpdateConcurrencyException ex)
{
await ex.Entries.Single().ReloadAsync();
throw; // let the retry policy run the whole operation again
}
ReloadAsync() refreshes the tracked entity (and its RowVersion) from the database so the next attempt is built on current data.
Bounding the retry
An unbounded while loop retrying a conflict is a way to hang a thread forever under sustained contention. The demo wraps the operation in a Polly retry policy:
_retryPolicy = Policy
.Handle<DbUpdateConcurrencyException>()
.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));
Three attempts, exponential backoff. If it still cannot win after that, the exception propagates and the caller gets a clear failure instead of a spinning request. In a real API you would surface that as a 409 Conflict.
One caveat on the exact backoff here: seconds-scale waits (2s, 4s, 8s) are long for an HTTP request. For an interactive endpoint, milliseconds with jitter is usually the better shape; the multi-second version is easier to observe in a demo.
Common pitfalls
- No concurrency token at all. Without
IsRowVersion()(or[Timestamp]), EF Core never adds theWHEREguard and the lost update happens silently. This is the default state of most projects. - Swallowing the exception without reloading. Retrying with the same stale
RowVersionjust fails again identically. - Retrying a non-idempotent absolute set. "Set balance to 150" retried after a conflict re-applies a decision made on stale data. Retry works cleanly for relative operations ("add 50"); for absolute ones, prefer database-wins.
- Unbounded retries. Always cap attempts and back off.
- Forgetting the migration. Adding
RowVersionchanges the schema - it needs its own migration before the guard exists in the database.
Key Takeaways
- Read-modify-write on shared data has a silent lost-update bug; optimistic concurrency is the standard fix for a web API.
IsRowVersion()on abyte[]property makes EF Core add the row's version to theWHEREclause of every update and delete.- A mismatch affects zero rows and raises
DbUpdateConcurrencyException- that is the conflict signal. - Resolve it by choosing client-wins, database-wins, or reload-and-retry; retry fits relative operations like balance changes.
ReloadAsync()refreshes the entity and its token before the next attempt.- Always bound retries with a capped, backing-off policy and return
409 Conflictwhen they are exhausted.
Get the Full Source Code
The complete runnable solution - the bank account model with the rowversion token, the repository with the caught concurrency exception and Polly retry, the migration, and the endpoints you can hammer concurrently - is available to Patreon supporters. If you want to reproduce the race and watch the guard fire instead of rebuilding it from the walkthrough above, you can find it on Patreon.