Common Concurrency Patterns in .NET Applications


Most concurrency bugs in .NET web apps are data consistency bugs, not threading bugs. The practical fix is usually to put the invariant where concurrent requests cannot bypass it: constraints, transactions, row versions, idempotency, and queues.

The failure mode is simple: two requests touch the same data at the same time.

Examples:

  • Two users try to book the same slot.
  • A retry sends the same command twice.
  • Two admins edit the same record.
  • A background job processes something the API is also updating.
  • Two workers pick up the same queued job.

The actual issue is simple: your code thinks it is the only thing changing the state, but it is not.

The quick map

Most app-level concurrency problems have straightforward fixes:

duplicate row problem -> unique constraint
several writes must succeed together -> transaction
two edits may overwrite each other -> row version
callback or retry may run twice -> idempotency
slow or external work -> queue
cross-process mutual exclusion -> consider a lock last

Isolation levels matter, but they are usually not the first tool to reach for. For most web apps, start with constraints, transactions, row versions, and idempotency.

Lost updates

A lost update is the concurrency bug many web apps actually feel.

Two requests read the same row, both make changes, and the last save overwrites the first save.

request A reads version 1
request B reads version 1
request A saves change
request B saves change
request A's change is gone

This can happen when two admins edit the same record, two workers process the same item, or two requests update the same order state.

A note on isolation levels

Databases have isolation levels, and they matter. But most application code does not get safer just because you memorized terms like dirty read, non-repeatable read, and phantom read.

For everyday .NET web apps, it is usually more useful to ask:

  • What rule must the database enforce?
  • Which writes must commit together?
  • What happens if this command runs twice?
  • What happens if someone else changed this row first?

Those questions lead to the tools this article focuses on: constraints, transactions, row versions, idempotency, queues, and careful retries.

Transactions

Use a transaction when several writes must succeed or fail together.

Example:

create booking -> reserve seat -> write audit entry

Those writes describe one state transition. If one write succeeds and another fails, the system can end up lying to itself.

In EF Core, SaveChanges already runs in a transaction for a normal batch of changes. Use an explicit transaction when you need to group multiple operations or multiple SaveChanges calls.

Unique constraints

Application checks are not enough when two requests can race.

This is fragile:

if no booking exists -> insert booking

Two requests can both pass the check before either insert commits.

Check-then-insert is not a concurrency control mechanism.

The database should enforce rules like:

one booking per slot
one active invite per team/email
one processed command/request id
one external event id per processed event

The app can still check first for a nicer error message, but the unique constraint is the final guard.

External APIs may also enforce idempotency with their own keys. That is separate from storing a unique command id, request id, or event id in your database so your own code can safely handle retries.

Optimistic concurrency

Optimistic concurrency means you allow people to edit data, but detect if someone else changed it before you saved.

In .NET with EF Core, this is often done with a concurrency token. A SQL Server rowversion column is one common option, but EF Core can also use another property as the token.

public class Booking
{
    public int Id { get; set; }
    public string Status { get; set; } = "";
    public byte[] RowVersion { get; set; } = [];
}
modelBuilder.Entity<Booking>()
    .Property(x => x.RowVersion)
    .IsRowVersion();

The token can also be an ordinary property when that property is the conflict boundary you care about:

modelBuilder.Entity<Booking>()
    .Property(x => x.Status)
    .IsConcurrencyToken();

Other data stores have their own versions of the same idea. Cosmos DB documents have an _etag, which is commonly used for optimistic concurrency.

The idea is:

update this row only if the token value is still the value I read

For state transitions, you can also protect the write directly with an expected status:

update booking
set status = Confirmed
where id = @id
and status = Requested

If the update affects zero rows, the booking was no longer in the state you expected.

With EF Core concurrency tokens, if the token value no longer matches, EF Core throws DbUpdateConcurrencyException. Then you decide what to do: reload, retry, show a conflict, or apply a merge.

This is useful for admin edits, profile updates, order status changes, and any state where silent overwrites would be bad.

Idempotency

Idempotency means the same operation can run more than once without causing the effect more than once.

This matters for retries, callbacks, command handlers, and background jobs.

In many systems, the realistic model is not exactly-once execution. It is at-least-once execution with handlers that are safe to run again.

Good behavior:

job already processed -> return success
invite already exists -> return success
email already queued -> do not queue it again

Idempotency is often just a combination of stored operation ids, status checks, transactions, and unique constraints.

Queues

Queues help when work is slow, external, or easier to process asynchronously.

Examples:

  • send email
  • process external events
  • generate reports
  • sync with another system

A queue does not remove the need for idempotency. Messages can be delivered more than once, workers can crash, and retries can happen.

Queue ordering is not a correctness guarantee unless your queue and partitioning strategy actually guarantee the ordering you depend on.

The handler should still be safe to run again.

Locks

Locks can be useful, but they are easy to overuse.

An in-process lock only protects one running app instance. If your app runs on two servers, the other server does not know about that lock.

Distributed locks can work, but they add operational complexity. For many web apps, database constraints, transactions, row versions, and queues are simpler and more reliable.

Retries

Retries are for transient failures, not for hiding broken state handling.

Retrying a database deadlock or temporary network failure can be fine. Retrying a non-idempotent operation can make things worse.

Before adding a retry, ask:

  • Is the operation safe to run again?
  • Is there a unique constraint or idempotency key?
  • Could this create duplicate rows, emails, jobs, or external side effects?

My default order

For normal .NET web apps, I usually think in this order:

  1. Put the business rule in the database when it must always hold.
  2. Use transactions for state changes that belong together.
  3. Use row versions when overwrites would be bad.
  4. Make commands and callbacks idempotent.
  5. Use queues for slow or external work.
  6. Reach for locks only when the simpler tools do not fit.

Takeaway

Most concurrency control in web apps is not about clever threading code.

It is about accepting that the same state can be touched twice: by two users, a retry, a callback, a background job, or another app instance.

The practical tools usually matter most: constraints, transactions, row versions, idempotency, queues, and careful retries.