Concurrency Control in .NET: From Local Locks to Distributed Systems

Mehdi Hadeli
@mehdihadeli
On this page
Table of contents
Introduction
Every e-commerce system faces the same challenge: multiple users competing for the same inventory slot. Two customers add the same "Gaming Laptop" to their cart with only one left. Who gets it? The answer depends on how you handle concurrency control, and getting it wrong means overselling inventory or losing orders.
There is no universal answer. The right strategy depends on your system architecture and contention level:
| System Type | Typical Deployments | Concurrency Risk | Best-Fit Strategy |
|---|---|---|---|
| Single-process monolith | 1 instance, 1 server | Thread interleaving | lock / SemaphoreSlim |
| Multi-instance monolith | 2+ instances behind load balancer | Cross-process races | Optimistic or distributed lock |
| Microservices | Services with independent data stores | Cross-service coordination | Distributed lock (Redis, ZooKeeper) |
| Serverless / FaaS | Ephemeral, short-lived functions | No shared memory at all | Distributed lock or database-level optimistic |
Each architectural tier demands a different tool, but developers often reach for the wrong one. Two common mistakes:
- Using
lockin a multi-instance deployment: each process has its own lock object, so the race condition returns the moment you scale out. - Introducing Redis for every concurrent operation: distributed locks add network round-trips, operational complexity, and a single point of failure. That is overkill for a single-instance background job that only needs
lock.
We will build a .NET e-commerce API that implements four concurrency strategies in the same codebase so you can see exactly when each one shines and where it breaks:
| # | Strategy | How It Works | Works Best When | Breaks When | Sample Use Case |
|---|---|---|---|---|---|
| 1 | NoLock | Reads and writes data freely — no waiting, no checking. Threads can trample each other's changes. | Nothing changes, or you only read data | Any write from two threads at once — data gets corrupted | Showing a product catalog where stale stock numbers are acceptable |
| 2 | LocalLock | One thread at a time enters the protected code. Others wait in line. | You run one copy of the app on one machine | You run two copies of the app — each has its own line, so both barge in together | Deducting stock in a monolith deployed on a single server |
| 3 | Optimistic | Everyone reads freely. Before saving, checks if data changed. If yes, retries a few times. | Multiple app instances, conflicts are rare | A flash sale — many people fight over the last item, retries pile up and slow down | Placing orders on a typical e-commerce site where overlaps are uncommon |
| 4 | Distributed | A central coordinator (Redis) hands out keys. Only whoever holds the key gets to act. | High-traffic items, or coordination across different services | Redis goes down, or the key expires early — handle with a fallback | Checking out the last unit of a limited-edition item during a flash sale |
The same codebase lets you switch strategies per request through a single enum parameter. All code comes from the runnable sample linked at the end.
Why Multi-Instance Changes Everything
A single instance of your application has one process, one memory space, and one set of lock objects. Thread A and Thread B both use the same lock (_localLock) statement, so they queue up and take turns. This works because the lock lives in the process memory, and there is only one process.
Deploy a second instance, and everything changes:
flowchart LR
subgraph LB["Load Balancer"]
direction LR
R1((Request 1))
R2((Request 2))
end
subgraph I1["Instance 1 (process A)"]
L1["lock _localLock<br/>(instance 1)"]
M1[(Memory)]
end
subgraph I2["Instance 2 (process B)"]
L2["lock _localLock<br/>(instance 2)"]
M2[(Memory)]
end
D[(Shared Database<br/>PostgreSQL 17)]
R1 --> I1
R2 --> I2
I1 --> L1 --> M1 --> D
I2 --> L2 --> M2 --> D
style L1 fill:#e94560,color:#fff
style L2 fill:#e94560,color:#fff
Each instance is its own process with its own memory. Instance 1's _localLock and Instance 2's _localLock are completely independent objects. Both threads can hold their respective locks simultaneously and both enter the critical section. The shared database sees two concurrent writes with no protection.
This is not a bug in the code. It is a fundamental property of distributed systems: processes do not share memory. Any concurrency mechanism that relies on shared memory (local lock, SemaphoreSlim, Monitor) becomes invisible to other processes instantly.
Here is how each strategy behaves when the deployment grows:
| Strategy | 1 instance | 3 instances | Why |
|---|---|---|---|
| NoLock | Corrupts | Corrupts (worse) | No coordination at all — more instances = more interleaving |
| LocalLock | Protects correctly | Crashes or corrupts | Each instance has its own lock; instances don't see each other. With EF Core + PostgreSQL, concurrent writes throw DbUpdateConcurrencyException -- unhandled, returns 500 HTML |
| Optimistic | Protects correctly | Protects correctly | Version check lives in the shared database, not in process memory |
| Distributed | Protects (overkill) | Protects correctly | RedLock lives in Redis, outside all instances |
LocalLock is the trap. A developer tests with one instance, gets green tests, deploys to Kubernetes with 3 replicas, and now has a production bug that is hard to reproduce locally. The integration tests in this sample use CreateSimulatedInstanceAsync() to spin up multiple WebApplicationFactory instances sharing the same Postgres and Redis backends, exactly like Kubernetes pods. The test Deduct_LocalLock_MultiInstance_Oversells proves the breakage: 3 instances, stock 30, all using LocalLock, result is negative stock.
Optimistic and Distributed strategies are the only ones that survive scale-out, which is why they get the most attention here.
Architecture Overview
The project follows Vertical Slice Architecture with MediatR. Each use case is a self-contained slice in Features/{Verb}{Noun}/v1/. Slices share abstractions via interfaces in Shared/Contracts/ but never couple to each other's internals. The DeductStock and PlaceOrder slices each implement all four strategies.
flowchart TB
C((Client))
subgraph GW["YARP Gateway"]
Y[Reverse Proxy<br/>:8080]
end
subgraph Instances["3 API Instances (Docker Compose)"]
I1["Instance 1<br/>ecommerce-api-1"]
I2["Instance 2<br/>ecommerce-api-2"]
I3["Instance 3<br/>ecommerce-api-3"]
end
subgraph App["Application Layer"]
subgraph Slice["Vertical Slice: DeductStock"]
REQ[DeductStockRequest]
H[DeductStockHandler]
RESP[DeductStockResponse]
end
subgraph Store["Abstractions"]
IPS[IProductStore]
O[IOrderStore]
IDLM[IDistributedLockManager]
end
end
subgraph Infra["Infrastructure Layer"]
subgraph Mem["In-Memory (dev)"]
IS[InventoryStore]
IM[InMemoryDistributedLockManager]
IOS[InMemoryOrderStore]
end
subgraph PG["EF Core + PostgreSQL"]
EPS[EfProductStore]
EOS[EfOrderStore]
DC[ECommerceDbContext]
DB[(PostgreSQL 17)]
end
subgraph Redis["Redis"]
RD[RedisDistributedLockManager]
R[(Redis RedLock)]
end
end
C --> Y
Y --> I1 & I2 & I3
I1 & I2 & I3 --> REQ
REQ --> H
H --> IPS
H --> O
H --> IDLM
IPS --> IS
IPS --> EPS
O --> IOS
O --> EOS
IDLM --> IM
IDLM --> RD
EPS --> DC
EOS --> DC
DC --> DB
RD --> R
classDef client fill:#6C3483,color:#fff,stroke:#4A235A,stroke-width:2px
classDef gateway fill:#e94560,color:#fff,stroke:#c81e45,stroke-width:2px
classDef instance fill:#D4AC0D,color:#fff,stroke:#B7950B,stroke-width:2px
classDef apiLayer fill:#4A90D9,color:#fff,stroke:#2E5A8A,stroke-width:2px
classDef appLayer fill:#27AE60,color:#fff,stroke:#1E8449,stroke-width:2px
classDef infraLayer fill:#E67E22,color:#fff,stroke:#CA6F1E,stroke-width:2px
classDef memLayer fill:#5DADE2,color:#fff,stroke:#2E86C1,stroke-width:2px
classDef pgLayer fill:#3498DB,color:#fff,stroke:#1F618D,stroke-width:2px
classDef redisLayer fill:#E74C3C,color:#fff,stroke:#922B21,stroke-width:2px
classDef db fill:#2C3E50,color:#fff,stroke:#1A252F,stroke-width:2px
classDef request fill:#8E44AD,color:#fff,stroke:#6C3483,stroke-width:2px
classDef handler fill:#2ECC71,color:#fff,stroke:#1E8449,stroke-width:2px
classDef abstraction fill:#5DADE2,color:#fff,stroke:#2E86C1,stroke-width:2px
classDef response fill:#58D68D,color:#fff,stroke:#2ECC71,stroke-width:2px
class C client
class Y gateway
class I1,I2,I3 instance
class REQ,RESP request
class H handler
class IPS,O,IDLM abstraction
class IS,IM,IOS memLayer
class EPS,EOS,DC pgLayer
class DB db
class RD redisLayer
class R db
How the Handler Dispatches Strategies
The handler class receives a ConcurrencyStrategy enum in the request and dispatches to the correct implementation:
// ConcurrencyStrategy.cs
public enum ConcurrencyStrategy
{
NoLock, // Unsafe — baseline for comparison
LocalLock, // lock/SemaphoreSlim — single-process only
Optimistic, // Version check + retry — works across instances
Distributed // External lock manager — strongest guarantee
}
The handler switches on the strategy at runtime:
// DeductStock.cs — handler dispatch
internal sealed class DeductStockHandler(
IProductStore productStore,
IDistributedLockManager? distributedLockManager = null
) : IRequestHandler<DeductStockRequest, DeductStockResponse>
{
private readonly object _localLock = new();
private const int MaxOptimisticRetries = 5;
public async Task<DeductStockResponse> Handle(
DeductStockRequest request,
CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
return request.Strategy switch
{
ConcurrencyStrategy.NoLock => HandleNoLock(request, sw),
ConcurrencyStrategy.LocalLock => HandleLocalLock(request, sw),
ConcurrencyStrategy.Optimistic => await HandleOptimisticAsync(request, sw, cancellationToken),
ConcurrencyStrategy.Distributed => await HandleDistributedAsync(request, sw, cancellationToken),
_ => throw new ArgumentOutOfRangeException(nameof(request.Strategy)),
};
}
}
IDistributedLockManager is a nullable constructor parameter. NoLock and LocalLock strategies never touch it.
DI Wiring: Choose Persistence and Locking at Startup
The StorageExtensions class reads two connection strings: "Postgres" for data persistence and "Redis" for distributed locking. Each toggle switches between in-memory and production implementations independently:
// StorageExtensions.cs
public static WebApplicationBuilder AddStorage(this WebApplicationBuilder builder)
{
// ── Product & order persistence ─────────────────────────
var postgresConnectionString = builder.Configuration.GetConnectionString("Postgres");
if (!string.IsNullOrEmpty(postgresConnectionString))
{
builder.Services.AddDbContextFactory<ECommerceDbContext>(options =>
options.UseNpgsql(postgresConnectionString));
builder.Services.AddSingleton<IProductStore, EfProductStore>();
builder.Services.AddSingleton<IOrderStore, EfOrderStore>();
}
else
{
builder.Services.AddSingleton<IProductStore, InventoryStore>();
builder.Services.AddSingleton<IOrderStore, InMemoryOrderStore>();
}
// ── Distributed lock ────────────────────────────────────
var redisConnectionString = builder.Configuration.GetConnectionString("Redis");
if (!string.IsNullOrEmpty(redisConnectionString))
{
builder.Services.AddSingleton(_ =>
ConnectionMultiplexer.Connect(redisConnectionString));
builder.Services.AddSingleton<IDistributedLockManager, RedisDistributedLockManager>();
}
else
{
builder.Services.AddSingleton<IDistributedLockManager, InMemoryDistributedLockManager>();
}
return builder;
}
The two dimensions are fully independent. You can run PostgreSQL with an in-memory lock, or Redis with an in-memory store, or all four together. Connection strings use standard appsettings.json format:
{
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5432;Database=ecommerce;Username=ecommerce;Password=ecommerce",
"Redis": "localhost:6379,abortConnect=false,connectTimeout=2000,connectRetry=1"
}
}
The Redis string uses abortConnect=false so the app starts even when Redis is not running.
Source: DeductStock.cs, ConcurrencyStrategy.cs, StorageExtensions.cs
The Problem: Race Conditions in Inventory
Without protection, concurrent requests interleave and corrupt state. Consider two requests that both see stock = 1 and both deduct:
sequenceDiagram
participant R1 as Request 1
participant S as In-Memory Store
participant R2 as Request 2
R1->>S: Read stock (1)
R2->>S: Read stock (1)
R1->>S: Write stock - 1 = 0
R2->>S: Write stock - 1 = 0
Note over S: Stock = 0, but 2 units<br/>were "sold" — oversold!
This is the classic lost-update problem. Each strategy below solves it differently or fails to.
Strategy 1: No Lock
Concept
The baseline: read, modify, write with no synchronization. Two requests can read the same stock value simultaneously, both think they have enough, both write back — and one write silently overwrites the other. NoLock is the default state of any endpoint that was never designed for concurrency.
Where You See It
NoLock is rarely chosen deliberately. It appears because developers did not realize concurrency was a concern:
- A shopping cart service in a startup's MVP that worked fine during the first 100 users, then silently lost orders after the first marketing launch.
- A reporting endpoint that aggregates analytics but occasionally skips events because two requests write the same counter at the same time.
- An admin panel that sets a product's discount flag — the write is technically idempotent, but the surrounding validation reads stale data and lets through invalid state.
NoLock is also appropriate for read-only endpoints (product catalogs where stale numbers are acceptable) and idempotent writes (setting a flag that already has the target value). For any mutation of shared mutable state, it will eventually corrupt your data.
Code
// DeductStock.cs — NoLock strategy
private DeductStockResponse HandleNoLock(
DeductStockRequest req,
Stopwatch sw)
{
var product = productStore.Get(req.ProductId);
if (product.Stock < req.Quantity)
return Fail("Insufficient stock", product.Stock, sw, ConcurrencyStrategy.NoLock);
Thread.Sleep(Random.Shared.Next(5, 15));
product.DeductStock(req.Quantity);
productStore.Write(product);
return Ok(product.Stock, 0, sw, ConcurrencyStrategy.NoLock);
}
A Thread.Sleep simulates realistic network/processing latency. Under concurrent load, this will oversell.
Pros, Cons, and When It Fits
| Pros | Cons |
|---|---|
| Highest throughput (zero overhead) | Guaranteed data corruption under concurrent writes |
| Simplest possible code | Lost updates, phantom reads, inconsistent state |
| Useful performance baseline | Not safe for any shared mutable state |
Where it fits: Read-only endpoints that serve public catalogs. Idempotent operations. Benchmarking to measure the overhead that other strategies introduce.
When Not to Use: Any code path that mutates shared state. Any endpoint that could receive two simultaneous requests. Do not use it for inventory deduction, order placement, balance transfers, seat reservation, or any counter-like operation.
Codebase scenario: DeductStock with ConcurrencyStrategy.NoLock. Two concurrent requests both read stock=1, both see enough quantity, both write stock=0. The response says success: true for both, but the second customer gets nothing.
Source: DeductStock.cs — HandleNoLock
Strategy 2: Local Lock
Concept
The simplest fix: wrap the critical section in a lock statement. Only one thread enters at a time. The lock lives in process memory, so it is fast (~50ns overhead) and requires zero infrastructure.
The key constraint: exactly one process must exist. Local lock cannot see locks held by other processes. Scale to two instances behind a load balancer, and both can hold their respective locks simultaneously.
Where You See It
Local locking is the right call when deployment guarantees a single process:
- A background worker in a console application that processes a queue of files one at a time on a single server.
- An in-memory cache that refreshes every five minutes — the timer callback fires, and you want to prevent a second call from refreshing while the first is still populating.
- A desktop application that writes configuration to a local file.
- A monolithic API deployed to a single EC2 instance with no plans to scale horizontally.
Code
// DeductStock.cs — LocalLock strategy
private readonly object _localLock = new();
private DeductStockResponse HandleLocalLock(
DeductStockRequest req,
Stopwatch sw)
{
lock (_localLock)
{
var product = productStore.Get(req.ProductId);
if (product.Stock < req.Quantity)
return Fail("Insufficient stock", product.Stock, sw, ConcurrencyStrategy.LocalLock);
Thread.Sleep(Random.Shared.Next(5, 15));
product.DeductStock(req.Quantity);
productStore.Write(product);
return Ok(product.Stock, 0, sw, ConcurrencyStrategy.LocalLock);
}
}
Works great with one process. Scales to two instances and the race condition returns.
What Happens When You Scale Out
With EF Core + PostgreSQL protecting the write via a concurrency token, the failure is not silent. When two instances write the same row simultaneously, EF Core detects the version mismatch and throws DbUpdateConcurrencyException. With no exception-handling middleware, the client receives a 500 HTML error page. In the k6 load tests (validated below), 30 concurrent LocalLock requests across 3 instances produced 27 crashes (90%), 3 successes, and final stock of 29 instead of 0. Both crashes and data corruption occurred.
Pros, Cons, and When It Fits
| Pros | Cons |
|---|---|
Simple — one keyword (lock) | Process-scoped — invisible to other instances |
| Zero infrastructure | Can deadlock if nested carelessly |
| Fastest mutual exclusion (~50ns) | No timeout — a stuck thread blocks forever |
lock recursion-safe in .NET | SemaphoreSlim needed for async code paths |
Where it fits: Single-instance background jobs, in-memory cache refresh, local rate-limiters, or a monolithic API that will never scale horizontally.
When Not to Use: As soon as you deploy a second instance behind a load balancer. Do not use local lock in Kubernetes deployments where pods auto-scale. Do not use it in microservices where the same service may run in multiple replicas. For async code paths, use SemaphoreSlim(1,1) instead — lock does not work with await.
Source: DeductStock.cs — HandleLocalLock
Strategy 3: Optimistic Concurrency
Concept
Instead of preventing conflicts, detect and retry. Each product carries a Version integer. Before writing, check that the version has not changed since you read it. If it has, retry, typically with exponential backoff. The core insight: the version check lives in the shared database, not in process memory, so it works across any number of application instances.
This is the default choice for roughly 80% of production scenarios because it requires zero infrastructure and handles the common case (rare conflicts) gracefully.
Where You See It
- An e-commerce product catalog backend with 50 instances behind a load balancer — requests occasionally conflict on the same SKU, but 99% of writes succeed on the first attempt.
- A social media platform storing user profile updates where two admins rarely edit the same profile simultaneously.
- A booking system for appointment scheduling where overlapping bookings are possible but uncommon.
Conflicts are the exception, not the rule. Most users never notice the retry because it completes within milliseconds.
The Retry Loop
// DeductStock.cs — Optimistic strategy
private async Task<DeductStockResponse> HandleOptimisticAsync(
DeductStockRequest req,
Stopwatch sw,
CancellationToken ct)
{
var retryCount = 0;
while (retryCount <= MaxOptimisticRetries)
{
ct.ThrowIfCancellationRequested();
var product = productStore.Get(req.ProductId);
if (product.Stock < req.Quantity)
return Fail("Insufficient stock", product.Stock, sw,
ConcurrencyStrategy.Optimistic, retryCount);
var (success, updated, _) = productStore.TryUpdate(
req.ProductId, product.Version, p =>
{
p.DeductStock(req.Quantity);
return p;
});
if (success)
return Ok(updated!.Stock, retryCount, sw, ConcurrencyStrategy.Optimistic);
retryCount++;
if (retryCount <= MaxOptimisticRetries)
await Task.Delay(
Random.Shared.Next(10, 50) * (int)Math.Pow(2, retryCount), ct);
}
return Fail("Max retries exceeded", productStore.Get(req.ProductId).Stock,
sw, ConcurrencyStrategy.Optimistic, retryCount);
}
The handler delegates the version-aware write to IProductStore.TryUpdate. Here is the in-memory implementation:
// InventoryStore.cs — optimistic write with version guard
public (bool success, Product? product, int storeVersion) TryUpdate(
Guid id,
int expectedVersion,
Func<Product, Product> transform)
{
lock (_lock) // protects the Dictionary, not the concurrency strategy
{
if (!_products.TryGetValue(id, out var current))
return (false, null, 0);
if (current.Version != expectedVersion)
return (false, Clone(current), current.Version);
var updated = transform(Clone(current));
updated.Version = current.Version + 1;
updated.UpdatedAtUtc = DateTime.UtcNow;
_products[id] = updated;
return (true, Clone(updated), updated.Version);
}
}
The version check prevents lost updates:
sequenceDiagram
participant R1 as Request 1 (v1)
participant S as Store
participant R2 as Request 2 (v1)
R1->>S: Read product (version=1)
R2->>S: Read product (version=1)
R1->>S: TryUpdate(id, version=1)
S->>R1: Success (version=2)
R2->>S: TryUpdate(id, version=1)
S->>R2: Fail (version=2)
R2->>S: Read product (version=2)
R2->>S: TryUpdate(id, version=2)
S->>R2: Success (version=3)
EF Core and PostgreSQL: Two Concurrency Token Types
Entity Framework Core provides two distinct mechanisms for optimistic concurrency. The sample ships with both configured side by side so you can compare them.
Database Auto-Generated Row Version (IsRowVersion)
A row version column is maintained entirely by the database. With Npgsql and PostgreSQL, .IsRowVersion() maps to the xmin system column — a hidden column in every PostgreSQL table that stores the transaction ID of the latest update. Every write increments xmin. The application never reads, sets, or increments this value:
// Product.cs
// Database auto-generated row version — PostgreSQL xmin system column.
// EF Core + Npgsql maps .IsRowVersion() to the xmin column.
public uint RowVersion { get; set; }
Application-Managed Concurrency Token (IsConcurrencyToken)
A concurrency token is managed explicitly by the application. The most common form is an integer Version that increments on every update:
// Product.cs
// Application-managed concurrency token — incremented manually in TryUpdate.
public int Version { get; internal set; }
Both Tokens in the DbContext
// ECommerceDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.ToTable("products");
entity.HasKey(p => p.Id);
entity.Property(p => p.Id).ValueGeneratedNever();
entity.Property(p => p.Name).IsRequired().HasMaxLength(200);
entity.Property(p => p.Stock).IsRequired();
entity.Property(p => p.Price).IsRequired().HasColumnType("decimal(18,2)");
// Application-managed concurrency token.
// Code must manually increment Version before SaveChanges.
entity.Property(p => p.Version).IsConcurrencyToken();
// Database auto-generated row version.
// PostgreSQL xmin column — auto-incremented on every row write.
entity.Property(p => p.RowVersion).IsRowVersion();
entity.Property(p => p.CreatedAtUtc).IsRequired();
entity.Property(p => p.UpdatedAtUtc).IsRequired();
});
}
When SaveChanges runs, EF Core generates SQL that checks both tokens:
UPDATE "products"
SET "Stock" = @p0, "Version" = @p1, "UpdatedAtUtc" = @p2
WHERE "Id" = @p3 AND "Version" = @p4 AND "RowVersion" = @p5;
-- If either token doesn't match, zero rows updated → DbUpdateConcurrencyException
The EF Core store implementation catches this exception explicitly:
// EfProductStore.cs — TryUpdate with EF Core
public (bool success, Product? product, int storeVersion) TryUpdate(
Guid id, int expectedVersion, Func<Product, Product> transform)
{
using var context = _contextFactory.CreateDbContext();
var product = context.Products.FirstOrDefault(p => p.Id == id);
if (product is null) return (false, null, 0);
if (product.Version != expectedVersion) return (false, Clone(product), product.Version);
transform(product);
product.Version = expectedVersion + 1;
product.UpdatedAtUtc = DateTime.UtcNow;
try
{
context.SaveChanges();
return (true, Clone(product), product.Version);
}
catch (DbUpdateConcurrencyException)
{
context.Entry(product).Reload(); // get current DB state for caller
return (false, Clone(product), product.Version);
}
}
Two concurrency tokens on the same entity is unusual for production — you typically pick one. Use .IsRowVersion() for a zero-touch, database-managed token. Use .IsConcurrencyToken() when you need application-level control (custom conflict resolution, cross-service versioning, conditional increments).
Pros, Cons, and When It Fits
| Pros | Cons |
|---|---|
| Works across any number of instances | Under high contention, retries spike latency |
| Zero infrastructure (no Redis, no extra DB) | Last writer wins — first writer's work is discarded |
| No locks held during processing | Exponential backoff can cause long tail latency |
| Scales naturally with read-heavy workloads | Bad UX for flash sales — users see conflicts at checkout |
Where it fits: The default choice for low-to-moderate contention CRUD. E-commerce product browsing, user profile updates, blog comments, wish lists, shopping carts. Any multi-instance deployment where conflicts happen but are not the norm.
When Not to Use: Flash sales where thousands of users fight over a single item. Checkout for luxury or limited-edition products where the last unit is contested. If your first-write success rate drops below 90%, the exponential backoff chain cascades and latency degrades. For these cases, add a distributed lock in front.
Codebase scenario: Five concurrent requests deducting from stock=100. Each TryUpdate checks the Version field. One succeeds immediately (version 1 to 2), the other four retry with exponential backoff (50ms, 100ms, 200ms, 400ms). All five eventually succeed, but the last one takes about 750ms instead of 10ms.
Source: DeductStock.cs — HandleOptimisticAsync, InventoryStore.cs — TryUpdate, EfProductStore.cs — TryUpdate, ECommerceDbContext.cs
Strategy 4: Distributed Lock (Redis RedLock) with Optimistic Safety Net
Concept
For the strongest guarantee, use an external lock manager. A distributed lock lives outside your application process, so it works across any number of instances, services, or even data centers. The sample uses the DistributedLock.Redis library by madelson, implementing the RedLock algorithm.
The implementation does not stop at a standalone distributed lock. It pairs it with optimistic concurrency inside the lock and a database CHECK constraint at the schema level. Three layers, each catching what the one above might miss.
Where You See It
- A flash sale platform coordinating inventory writes across 200 API instances — a local lock is useless and optimistic retries alone cause a thundering herd of exponential backoff.
- A scheduled job runner on Kubernetes where exactly one pod must generate the daily report, and the other nine pods must skip it.
- A microservice that deducts wallet balance in Service A but must coordinate with a ledger write in Service B — the lock lives in Redis, which both services share.
The Interface
All strategies depend on an abstraction, not a concrete implementation:
// IDistributedLockManager.cs
public interface IDistributedLockManager
{
Task<LockLease?> TryAcquireAsync(
string resourceKey,
TimeSpan ttl,
CancellationToken ct = default);
Task ReleaseAsync(LockLease lease);
}
public sealed record LockLease(string ResourceKey, string OwnerId, DateTime ExpiresAt);
Redis Implementation
// RedisDistributedLockManager.cs
public sealed class RedisDistributedLockManager : IDistributedLockManager
{
private readonly ConnectionMultiplexer _mux;
private readonly IDatabase _database;
private readonly ConcurrentDictionary<string, RedisDistributedLockHandle> _handles = new();
public RedisDistributedLockManager(ConnectionMultiplexer multiplexer, int databaseIndex = 0)
{
_mux = multiplexer;
_database = multiplexer.GetDatabase(databaseIndex);
}
public async Task<LockLease?> TryAcquireAsync(
string resourceKey, TimeSpan ttl, CancellationToken ct = default)
{
if (!_mux.IsConnected) // Fast-fail if Redis is unreachable
return null;
try
{
var acquireTimeout = TimeSpan.FromSeconds(1);
var redisLock = new RedisDistributedLock(resourceKey, _database);
var handle = await redisLock.TryAcquireAsync(acquireTimeout, ct);
if (handle is null) return null;
var ownerId = Guid.NewGuid().ToString("N");
_handles[ownerId] = handle;
return new LockLease(resourceKey, ownerId, DateTime.UtcNow.Add(ttl));
}
catch (RedisConnectionException)
{
return null; // Clean degradation — caller returns 409, not 500
}
}
public Task ReleaseAsync(LockLease lease)
{
if (_handles.TryRemove(lease.OwnerId, out var handle))
handle.Dispose();
return Task.CompletedTask;
}
}
Key design decisions:
- Fast-fail:
!_mux.IsConnectedreturns null immediately rather than blocking - Short acquire timeout: 1 second, separate from the lock TTL
- Safe degradation:
RedisConnectionExceptionreturns null — the caller returns a 409 Conflict - Handle tracking:
ConcurrentDictionaryensuresReleaseAsyncdisposes the correct lock handle
In-Memory Fallback for Development
// InMemoryDistributedLockManager.cs
public sealed class InMemoryDistributedLockManager : IDistributedLockManager
{
private readonly ConcurrentDictionary<string, LockLease> _locks = new();
public async Task<LockLease?> TryAcquireAsync(
string resourceKey, TimeSpan ttl, CancellationToken ct = default)
{
// Clean expired leases on each acquire attempt
foreach (var (key, existing) in _locks)
if (DateTime.UtcNow >= existing.ExpiresAt)
_locks.TryRemove(key, out _);
var ownerId = Guid.NewGuid().ToString("N");
var lease = new LockLease(resourceKey, ownerId, DateTime.UtcNow.Add(ttl));
var deadline = DateTime.UtcNow.Add(TimeSpan.FromSeconds(1));
while (DateTime.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
if (_locks.TryAdd(resourceKey, lease))
return lease;
await Task.Delay(Random.Shared.Next(5, 20), ct);
}
return null;
}
public Task ReleaseAsync(LockLease lease)
{
if (_locks.TryGetValue(lease.ResourceKey, out var current)
&& current.OwnerId == lease.OwnerId)
_locks.TryRemove(lease.ResourceKey, out _);
return Task.CompletedTask;
}
}
The 3-Layer Hybrid Handler
The handler combines all three layers in a single method:
// DeductStock.cs — Distributed strategy (3-layer hybrid)
private async Task<DeductStockResponse> HandleDistributedAsync(
DeductStockRequest req,
Stopwatch sw,
CancellationToken ct)
{
if (distributedLockManager is null)
return Fail("No distributed lock manager configured", 0,
sw, ConcurrencyStrategy.Distributed);
// Layer 1: Distributed lock — fast coordination for UX
var lease = await distributedLockManager.TryAcquireAsync(
$"product-lock-{req.ProductId}",
TimeSpan.FromSeconds(5), ct);
if (lease is null)
return Fail("Distributed lock timeout", 0,
sw, ConcurrencyStrategy.Distributed);
try
{
// Layer 2: Optimistic safety net inside the lock
const int maxRetries = 3;
var retryCount = 0;
while (retryCount <= maxRetries)
{
ct.ThrowIfCancellationRequested();
var product = productStore.Get(req.ProductId);
if (product.Stock < req.Quantity)
return Fail("Insufficient stock", product.Stock,
sw, ConcurrencyStrategy.Distributed);
var (success, updated, _) = productStore.TryUpdate(
req.ProductId, product.Version,
p => { p.DeductStock(req.Quantity); return p; });
if (success)
return Ok(updated!.Stock, retryCount, sw, ConcurrencyStrategy.Distributed);
retryCount++;
if (retryCount <= maxRetries)
await Task.Delay(Random.Shared.Next(10, 30), ct);
}
return Fail("Max retries exceeded", productStore.Get(req.ProductId).Stock,
sw, ConcurrencyStrategy.Distributed);
}
finally
{
await distributedLockManager.ReleaseAsync(lease);
}
}
The lock key is scoped to the specific product (product-lock-{productId}), so different products never contend for the same lock.
Layer 3 (database CHECK constraint) sits at the schema level:
ALTER TABLE Products ADD CONSTRAINT CK_Stock_NonNegative CHECK (Stock >= 0);
Why Three Layers Matter
A common misconception is that a distributed lock alone guarantees data integrity. It does not. Lock expiration, network partitions, and GC pauses can all break the illusion of exclusive access:
T+0s: Instance A acquires Redis lock (TTL = 5s)
T+4s: Instance A hits a long GC pause...
T+5s: Redis lock EXPIRES automatically
T+6s: Instance B acquires lock (thinks it's free)
T+7s: Instance A resumes — still holding stale lock reference
T+8s: Instance A writes stock -= 1 → stock = 0
T+9s: Instance B writes stock -= 1 → stock = -1 ← CORRUPTION!
The three-layer architecture prevents this:
flowchart TB
subgraph Layers["Layered Protection Strategy"]
direction TB
L1["Layer 1: Distributed Lock<br/><i>Fast coordination — UX</i>"]
L2["Layer 2: Optimistic Version Check<br/><i>Mid-layer safety net</i>"]
L3["Layer 3: Database Constraint<br/><i>Physical guarantee</i>"]
end
R1((Request)) --> L1
L1 -->|Lock acquired| L2
L1 -->|Timeout| Timeout["409 Conflict<br/>Return to client"]
L2 -->|Version matches → Commit| L3
L2 -->|Version mismatch| Retry["Retry with<br/>new version"]
Retry --> L2
L3 -->|CHECK Stock >= 0| OK[("Consistent State<br/>Transaction Committed")]
L3 -->|Constraint violation| Reject["Rollback<br/>Return error"]
style L1 fill:#e94560,color:#fff,stroke:#c81e45,stroke-width:2px
style L2 fill:#533483,color:#fff,stroke:#3d1f63,stroke-width:2px
style L3 fill:#0f3460,color:#fff,stroke:#09203f,stroke-width:2px
style Timeout fill:#dc3545,color:#fff,stroke:#a71d2a,stroke-width:2px
style Retry fill:#ffc107,color:#333,stroke:#d39e00,stroke-width:2px
style OK fill:#28a745,color:#fff,stroke:#1e7e34,stroke-width:2px
style Reject fill:#dc3545,color:#fff,stroke:#a71d2a,stroke-width:2px
style R1 fill:#007bff,color:#fff,stroke:#0056b3,stroke-width:2px
style Layers fill:#f8f9fa,stroke:#dee2e6,stroke-width:2px,color:#333
- Distributed lock gives immediate feedback. Users do not wait for retries across network hops.
- Optimistic version check protects against lock expiration, GC pauses, and network splits.
- Database
CHECKconstraint provides a physical guarantee. Even if both upper layers fail, the database rejects negative stock.
The golden rule: Distributed locks handle coordination and give users fast rejection. Optimistic concurrency or database constraints handle correctness. Never trust a distributed lock alone for data integrity.
Flash Sales: Why Three Layers Matter
Flash sales (PS5 launch, concert tickets, limited-edition sneakers) are the highest-contention scenario in e-commerce. Thousands of users target the same SKU simultaneously.
What happens with Optimistic alone?
Consider 30 concurrent requests for 30 units, each deducting 1, with MaxOptimisticRetries = 5:
T+0ms: 30 requests read stock = 30, version = 1
T+1ms: 1 request succeeds (version 1→2)
T+2ms: Remaining 29 retry with exponential backoff (20-100ms delay)
T+50ms: 1 more succeeds from the retry batch
T+150ms: Another batch reads stale versions and retries again...
...
T+5s: Only ~8-12 requests succeeded. Rest return 409 "Max retries exceeded".
Latency for failed requests: up to 3 seconds each
The problem is the retry cascade. Each retry doubles the delay (delay * 2^retry), so requests pile up waiting for backoff to expire.
The 3-layer approach solves this:
T+0ms: 30 requests arrive at the handler
T+1ms: 1 acquires the distributed lock, the other 29 get immediate 409
T+5ms: Inside the lock, TryUpdate succeeds on first attempt (retryCount=0)
T+10ms: Lock released, next request acquires it
...
T+300ms: All 30 requests completed. 30 succeed (200 OK), 0 fail to starvation.
The 29 "rejected" requests got a fast 409 in under 1ms.
The distributed lock front-loads the rejection so most users get an instant answer. The optimistic safety net inside protects against the rare lock expiration.
Pros, Cons, and When It Fits
| Pros | Cons |
|---|---|
| Strongest guarantee — coordinates across instances | Requires external infrastructure (Redis) |
| Works across microservice boundaries | Network round-trip adds latency (~1-5ms) |
| Configurable TTL — auto-expires on crash | Can still fail — lock expiration, GC pauses |
| Clean UX — fast rejection (no retry loops) | Needs fencing tokens for full safety |
Where it fits: High-contention inventory (flash sales, luxury item checkout), cross-service coordination, scheduled job prevention across Kubernetes pods, leader election. Use when the cost of retries outweighs the cost of Redis.
When Not to Use: If you do not already run Redis, think carefully before adding it just for locking. For a single-instance deployment, local lock is simpler and faster. For low-contention multi-instance scenarios, optimistic concurrency alone works without extra infrastructure.
When to pair with Optimistic fallback: Always. The 3-layer approach is not just for flash sales. Use it for any high-contention operation where lock expiration could cause a stale write.
Source: DeductStock.cs — HandleDistributedAsync, RedisDistributedLockManager.cs, InMemoryDistributedLockManager.cs, IDistributedLockManager.cs
When to Use Which Strategy
flowchart LR
subgraph Decision["Strategy Decision Tree"]
A[Need concurrency control?] -->|No| B[NoLock]
A -->|Yes| C[Single process?]
C -->|Yes| D[LocalLock]
C -->|No| E[Accept retries?]
E -->|Yes| F[Optimistic]
E -->|No| G[Distributed RedLock]
end
style A fill:#1a1a2e,stroke:#e94560,color:#fff
style B fill:#16213e,stroke:#e94560,color:#fff
style C fill:#1a1a2e,stroke:#e94560,color:#fff
style D fill:#0f3460,stroke:#e94560,color:#fff
style E fill:#1a1a2e,stroke:#e94560,color:#fff
style F fill:#533483,stroke:#e94560,color:#fff
style G fill:#e94560,stroke:#fff,color:#fff
| Strategy | Correctness | Throughput | Infrastructure | Best For |
|---|---|---|---|---|
| NoLock | None | Highest | None | Benchmarks, idempotent writes |
| LocalLock | Single process | High | None | Background jobs, single-instance APIs |
| Optimistic | Multi-instance | Medium-High | None | Low-contention CRUD, read-heavy workloads |
| Distributed | Strongest | Medium | Redis, ZooKeeper, etc. | High-contention inventory, financial transactions |
Scenario Decision Matrix
| Scenario | Recommended Strategy | Rationale |
|---|---|---|
| Read-only catalog (no writes) | NoLock | No shared mutable state to protect |
| In-memory cache refresh | Local lock (lock/SemaphoreSlim) | Single process only — no cross-instance coordination needed |
| High-volume cheap items (toothpaste, batteries) | Optimistic concurrency | Low contention, simple, no extra infra |
| Medium-value items (Nintendo Switch) | Optimistic + retry | Handles occasional conflicts with retries |
| High-value items (luxury watch, last unit) | Distributed lock + Optimistic safety net | Distributed prevents most conflicts, optimistic catches lock expiration |
| Flash sales (PS5 launch) | Distributed lock + Optimistic + database CHECK | High contention — immediate feedback to users, 3-layer protection |
| Scheduled jobs (daily report generation) | Distributed lock with TTL | Prevent duplicate execution across pods |
| Financial transactions (wallet balance) | Optimistic with database constraint | Version checks within a DB transaction; CHECK constraint as physical backstop |
The Contention Spectrum
Low Contention ←──────────────────────────────────────────────→ High Contention
│ │
▼ ▼
Optimistic Concurrency Distributed Lock + Safety Net
│ │
├── Most e-commerce CRUD ├── Flash sales
├── User profiles ├── Luxury item checkout
├── Blog comments └── Ticketmaster queues
└── Wish lists
The Real-World Pattern
Most production systems use a layered approach:
- Optimistic concurrency is the default. Zero infrastructure, good enough for most requests.
- Distributed locking protects high-contention resources (flash sales, popular SKUs).
- Local locking handles in-process coordination for background workers.
- NoLock is reserved for endpoints that do not modify state.
Project Structure for Reference
The full sample is organized using Vertical Slice Architecture:
src/ECommerce.App/
├── Inventory/
│ ├── ConcurrencyStrategy.cs # The 4-strategy enum
│ └── Features/
│ └── DeductingStock/v1/
│ ├── DeductStock.cs # Handler + all 4 strategies
│ └── DeductStockEndpoints.cs # Minimal API endpoint
├── Orders/
│ ├── Models/Order.cs
│ └── Features/
│ ├── PlacingOrder/v1/ # Also implements all 4 strategies
│ └── GettingOrder/v1/
├── Products/
│ ├── Models/Product.cs
│ └── Features/
│ ├── CreatingProduct/v1/
│ ├── GettingProduct/v1/
│ └── ListingProducts/v1/
└── Shared/
├── Contracts/
│ ├── IDistributedLockManager.cs
│ ├── IOrderStore.cs
│ └── IProductStore.cs
└── Data/
├── ECommerceDbContext.cs # EF Core DbContext (PostgreSQL)
├── EfOrderStore.cs # PostgreSQL-backed order store
├── EfProductStore.cs # PostgreSQL-backed product store
├── InMemoryDistributedLockManager.cs
├── InMemoryOrderStore.cs # In-memory order store (dev)
├── InventoryStore.cs # In-memory product store (dev)
└── RedisDistributedLockManager.cs
The code is available at github.com/mehdihadeli/blog-samples/tree/main/dotnet-concurrency-control.
Validation: Integration Tests, Docker Compose, and k6 Load Testing
The article's claims are backed by three validation layers: integration tests with multi-instance simulation, a full Docker Compose environment, and k6 load tests that stress all four strategies under realistic traffic.
Integration Testing with Multiple Instances
Unit tests and single-instance integration tests only cover half the story. To validate distributed locking correctly, tests must simulate multiple application instances contending for the same resource. The sample uses WebApplicationFactory to create independent DI containers that share the same Postgres and Redis backends — exactly like Kubernetes pods:
// ECommerceIntegrationTestBase.cs
protected async Task<HttpClient> CreateSimulatedInstanceAsync()
{
var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
{
builder.UseSetting("ConnectionStrings:Postgres", SharedFixture.Postgres.ConnectionString);
builder.UseSetting("ConnectionStrings:Redis", SharedFixture.Redis.ConnectionString);
});
var client = factory.CreateClient();
_simulatedInstances.Add((factory, client));
return client;
}
The test fixtures start real Postgres 17 and Redis 7.4 containers via Testcontainers. Each test class shares the same containers and resets data between tests via Respawn (Postgres) and FLUSHDB (Redis).
The most revealing test fires 30 concurrent requests from 3 simulated instances:
[Fact]
public async Task Deduct_Distributed_MultiInstance_FlashSale_AllSucceed()
{
var created = await CreateProductAsync("Flash Sale Item", 30, 50m);
var instance2 = await CreateSimulatedInstanceAsync();
var instance3 = await CreateSimulatedInstanceAsync();
// 10 requests per instance = 30 total
var tasks = new List<Task<HttpResponseMessage>>();
tasks.AddRange(Enumerable.Range(0, 10).Select(_ =>
DeductStockAsync(Client, product.ProductId, 1, "Distributed")));
tasks.AddRange(Enumerable.Range(0, 10).Select(_ =>
DeductStockAsync(instance2, product.ProductId, 1, "Distributed")));
tasks.AddRange(Enumerable.Range(0, 10).Select(_ =>
DeductStockAsync(instance3, product.ProductId, 1, "Distributed")));
var responses = await Task.WhenAll(tasks);
Assert.Equal(30, responses.Count(r => r.IsSuccessStatusCode));
Assert.Equal(0, responses.Count(r => r.StatusCode == HttpStatusCode.Conflict));
}
There is also a test that proves LocalLock breaks across instances — 3 instances, stock 30, all using LocalLock, result is negative stock.
# Run integration tests
dotnet test --filter "FullyQualifiedName~DeductStockTests"
Source: DeductStockTests.cs, ECommerceIntegrationTestBase.cs
Docker Compose: Full Multi-Instance Environment
The Docker Compose setup defines 6 services — a YARP gateway, 3 independent API instances, PostgreSQL 17, and Redis 7.4. The gateway uses PowerOfTwoChoices load balancing. Because each instance is a separate process with its own DI container, local locks cannot coordinate across them:
# deployments/docker-compose.yaml (abbreviated)
services:
gateway:
build: { context: .., dockerfile: deployments/Dockerfile.gateway }
ports: ['8080:8080']
ecommerce-api-1: &app-base
build: { context: .., dockerfile: deployments/Dockerfile.app }
environment:
ConnectionStrings__Postgres: 'Host=postgres;Port=5432;Database=ecommerce;Username=ecommerce;Password=ecommerce'
ConnectionStrings__Redis: 'redis:6379,abortConnect=false'
ecommerce-api-2: { <<: *app-base }
ecommerce-api-3: { <<: *app-base }
postgres:
image: postgres:17
environment: { POSTGRES_USER: ecommerce, POSTGRES_PASSWORD: ecommerce, POSTGRES_DB: ecommerce }
redis:
image: redis/redis-stack:7.4.0-v0
docker compose -f deployments/docker-compose.yaml up -d --build
Source: docker-compose.yaml
k6 Load Testing: Real Numbers Under Load
Four k6 scripts run against the Docker Compose environment (3 API replicas behind YARP) and confirm the article's claims with real numbers.
Scenario 1: Low Contention
Setup: 20 VUs for 15 seconds. Each VU creates its own product and deducts with all four strategies.
| Metric | Result |
|---|---|
| Strategy success | 100% (288/288) |
| Oversells detected | 0 |
| p(95) latency | 33.7ms |
When every request targets a distinct resource, all strategies produce identical results. Contention drives the differences, not strategy overhead.
Scenario 2: Medium Contention
Setup: 40 VUs staged (10s ramp-up, 20s steady, 10s ramp-down). Five shared products with 100 stock each. Even VUs use Optimistic, odd VUs use Distributed.
| Metric | Optimistic | Distributed |
|---|---|---|
| Total requests | 2,523 at ~69/s | |
| Total successful | 500/500 (matches stock exactly) | |
| Retry count | 12 | 4 |
| p(95) latency | 10.4ms |
Both strategies prevent overselling. Distributed uses one-third the retries of Optimistic — the Redis lock front-loads coordination.
Scenario 3: Flash Sale (High Contention)
Setup: 50 VUs for 15 seconds. Two products, each with stock=30. Even VUs target one product with Optimistic, odd VUs target the other with Distributed.
| Metric | Optimistic | Distributed |
|---|---|---|
| Behavior | Retry cascade — each retry doubles the delay | Fast rejection — 409 returned immediately |
| 409 rejection rate | ~49% (after exhausting retries) | ~68% (instant lock rejection) |
| 200 success rate | ~51% | ~32% |
Under high contention, Optimistic degrades: clients wait through exponential backoff before being told the stock ran out. Distributed gives an immediate answer — either acquire the lock and succeed, or get a fast 409. The user experience difference is dramatic (seconds versus milliseconds).
Scenario 4: LocalLock Multi-Instance Breakage
Setup: 30 VUs, one iteration each, all targeting the same product with stock=30 using LocalLock. Three Docker Compose replicas each have an independent _localLock object.
| Metric | Result |
|---|---|
| API crashes (500 HTML) | 27 out of 30 (90%) |
| Successful deductions | 3 out of 30 |
| Final stock in database | 29 (expected 0) |
LocalLock is catastrophically unsafe in a multi-instance deployment. DbUpdateConcurrencyException crashes the handler with no middleware to translate it to a clean response. Both API crashes and data corruption occur.
# Run all k6 scenarios
docker compose -f docker-compose.yaml -f docker-compose.k6.yml run --rm k6-low-contention
docker compose -f docker-compose.yaml -f docker-compose.k6.yml run --rm k6-medium-contention
docker compose -f docker-compose.yaml -f docker-compose.k6.yml run --rm k6-flash-sale
docker compose -f docker-compose.yaml -f docker-compose.k6.yml run --rm k6-locallock-breakage
Source: k6 scenarios
Conclusion
Concurrency control is a spectrum, not a binary choice. The right strategy depends on your scale, contention level, and tolerance for infrastructure complexity.
We walked through four strategies in the same codebase, from the raw unsynchronized baseline to a production-grade 3-layer hybrid. Each solves a specific set of problems:
- Start simple:
lockworks perfectly for single-process applications - Optimistic concurrency is the default choice for multi-instance services. No infrastructure needed, works at any scale.
- Distributed locks are for the hot path: the 5% of operations where contention is high enough that retries hurt. Always pair with optimistic concurrency as a safety net.
- Abstract the lock manager behind an interface so you can swap implementations without touching business logic.
- Never trust a distributed lock alone for data integrity. Always pair it with optimistic concurrency or a database constraint as your safety net.
- Test with real multi-instance setups, not mocks. Spinning up multiple
WebApplicationFactoryinstances with shared Testcontainers (Postgres + Redis) catches the exact race conditions that single-factory tests miss. - When in doubt, use optimistic concurrency. It is safe, performant, and requires zero external infrastructure. For the 80% of scenarios where conflicts are rare, it is the optimal choice.
The complete, runnable sample is on GitHub. Clone it, run it with 20 concurrent requests, and compare the results across strategies.


