34 min readMehdi Hadeli

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

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 TypeTypical DeploymentsConcurrency RiskBest-Fit Strategy
Single-process monolith1 instance, 1 serverThread interleavinglock / SemaphoreSlim
Multi-instance monolith2+ instances behind load balancerCross-process racesOptimistic or distributed lock
MicroservicesServices with independent data storesCross-service coordinationDistributed lock (Redis, ZooKeeper)
Serverless / FaaSEphemeral, short-lived functionsNo shared memory at allDistributed lock or database-level optimistic

Each architectural tier demands a different tool, but developers often reach for the wrong one. Two common mistakes:

  • Using lock in 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:

#StrategyHow It WorksWorks Best WhenBreaks WhenSample Use Case
1NoLockReads and writes data freely — no waiting, no checking. Threads can trample each other's changes.Nothing changes, or you only read dataAny write from two threads at once — data gets corruptedShowing a product catalog where stale stock numbers are acceptable
2LocalLockOne thread at a time enters the protected code. Others wait in line.You run one copy of the app on one machineYou run two copies of the app — each has its own line, so both barge in togetherDeducting stock in a monolith deployed on a single server
3OptimisticEveryone reads freely. Before saving, checks if data changed. If yes, retries a few times.Multiple app instances, conflicts are rareA flash sale — many people fight over the last item, retries pile up and slow downPlacing orders on a typical e-commerce site where overlaps are uncommon
4DistributedA central coordinator (Redis) hands out keys. Only whoever holds the key gets to act.High-traffic items, or coordination across different servicesRedis goes down, or the key expires early — handle with a fallbackChecking 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:

Rendering diagram...

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:

Strategy1 instance3 instancesWhy
NoLockCorruptsCorrupts (worse)No coordination at all — more instances = more interleaving
LocalLockProtects correctlyCrashes or corruptsEach instance has its own lock; instances don't see each other. With EF Core + PostgreSQL, concurrent writes throw DbUpdateConcurrencyException -- unhandled, returns 500 HTML
OptimisticProtects correctlyProtects correctlyVersion check lives in the shared database, not in process memory
DistributedProtects (overkill)Protects correctlyRedLock 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.

Rendering diagram...

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:

Rendering diagram...

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

ProsCons
Highest throughput (zero overhead)Guaranteed data corruption under concurrent writes
Simplest possible codeLost updates, phantom reads, inconsistent state
Useful performance baselineNot 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

ProsCons
Simple — one keyword (lock)Process-scoped — invisible to other instances
Zero infrastructureCan deadlock if nested carelessly
Fastest mutual exclusion (~50ns)No timeout — a stuck thread blocks forever
lock recursion-safe in .NETSemaphoreSlim 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:

Rendering diagram...

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

ProsCons
Works across any number of instancesUnder 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 processingExponential backoff can cause long tail latency
Scales naturally with read-heavy workloadsBad 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.IsConnected returns null immediately rather than blocking
  • Short acquire timeout: 1 second, separate from the lock TTL
  • Safe degradation: RedisConnectionException returns null — the caller returns a 409 Conflict
  • Handle tracking: ConcurrentDictionary ensures ReleaseAsync disposes 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 = -1CORRUPTION!

The three-layer architecture prevents this:

Rendering diagram...
  1. Distributed lock gives immediate feedback. Users do not wait for retries across network hops.
  2. Optimistic version check protects against lock expiration, GC pauses, and network splits.
  3. Database CHECK constraint 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 12)
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

ProsCons
Strongest guarantee — coordinates across instancesRequires external infrastructure (Redis)
Works across microservice boundariesNetwork round-trip adds latency (~1-5ms)
Configurable TTL — auto-expires on crashCan 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

Rendering diagram...
StrategyCorrectnessThroughputInfrastructureBest For
NoLockNoneHighestNoneBenchmarks, idempotent writes
LocalLockSingle processHighNoneBackground jobs, single-instance APIs
OptimisticMulti-instanceMedium-HighNoneLow-contention CRUD, read-heavy workloads
DistributedStrongestMediumRedis, ZooKeeper, etc.High-contention inventory, financial transactions

Scenario Decision Matrix

ScenarioRecommended StrategyRationale
Read-only catalog (no writes)NoLockNo shared mutable state to protect
In-memory cache refreshLocal lock (lock/SemaphoreSlim)Single process only — no cross-instance coordination needed
High-volume cheap items (toothpaste, batteries)Optimistic concurrencyLow contention, simple, no extra infra
Medium-value items (Nintendo Switch)Optimistic + retryHandles occasional conflicts with retries
High-value items (luxury watch, last unit)Distributed lock + Optimistic safety netDistributed prevents most conflicts, optimistic catches lock expiration
Flash sales (PS5 launch)Distributed lock + Optimistic + database CHECKHigh contention — immediate feedback to users, 3-layer protection
Scheduled jobs (daily report generation)Distributed lock with TTLPrevent duplicate execution across pods
Financial transactions (wallet balance)Optimistic with database constraintVersion 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.

MetricResult
Strategy success100% (288/288)
Oversells detected0
p(95) latency33.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.

MetricOptimisticDistributed
Total requests2,523 at ~69/s
Total successful500/500 (matches stock exactly)
Retry count124
p(95) latency10.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.

MetricOptimisticDistributed
BehaviorRetry cascade — each retry doubles the delayFast 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.

MetricResult
API crashes (500 HTML)27 out of 30 (90%)
Successful deductions3 out of 30
Final stock in database29 (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: lock works 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 WebApplicationFactory instances 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.