#dotnet#concurrency#nuget#open-source

Per-Key Locking in .NET: How and Why I Built LockIt

·4 min read·10 views

Two requests for the same order land at exactly the same time. Both read the current state. Both apply changes. Now you have a race condition. Classic problem.

The usual fix is a lock statement or a SemaphoreSlim — but what if you have thousands of different order IDs and you only want to block on the same order, not stop all orders from processing?

That's the problem LockIt solves.

What is LockIt?

LockIt is a lightweight, async-first NuGet package for per-key locking in .NET. The core idea: operations on the same key are serialized, but operations on different keys run in full parallel.

await using (await locker.AcquireAsync("order-123"))
{
    await ProcessOrderAsync("order-123");
}

While order-123 is being processed, order-456 can proceed completely unblocked. No one waits for anyone they don't need to wait for.

The Problem It Solves

I've seen this pattern come up repeatedly in backend work:

  • E-commerce — processing the same order twice due to double-click or retry logic
  • Document processing — concurrent edits to the same record
  • Per-user rate limiting — serializing operations for a specific user ID
  • Cache stampede prevention — ensuring only one request rebuilds a cached value per key

The standard lock (obj) doesn't work here — you'd need a different object per key, which means a dictionary of locks you need to manage carefully. When do you create them? When do you remove them? What happens if you remove a lock while someone is trying to acquire it?

Managing that yourself is surprisingly fiddly. You end up with either memory leaks (never removing locks) or subtle race conditions (removing a lock at the wrong moment).

LockIt handles all of that.

Installation

dotnet add package NLTechnologies.LockIt

Minimal Example

using NLTechnologies.LockIt;

await using var locker = new AsyncKeyedLocker<string>(logger);

await using (await locker.AcquireAsync("user-42"))
{
    // Only one concurrent operation per user ID runs at a time
    await UpdateUserProfileAsync("user-42");
}

The await using on both the locker and the lease is intentional — the lease is an IAsyncDisposable that releases the lock when disposed.

With Dependency Injection

Register in Program.cs:

builder.Services.AddLockIt();

Then inject wherever needed:

public class OrderService
{
    private readonly IAsyncKeyedLocker<string> _locker;

    public OrderService(IAsyncKeyedLockerFactory factory)
    {
        _locker = factory.Create<string>();
    }

    public async Task HandleAsync(string orderId, CancellationToken ct)
    {
        await using (await _locker.AcquireAsync(orderId, cancellationToken: ct))
        {
            await ProcessOrderAsync(orderId);
        }
    }
}

The Try-Pattern (Non-Throwing Timeouts)

If you don't want an exception on timeout, use TryAcquireAsync:

await using var result = await locker.TryAcquireAsync("resource", TimeSpan.FromSeconds(5));

if (result.Acquired)
{
    // Do the work
}
else
{
    // Handle gracefully — no exception thrown
}

Configuration

var options = new AsyncKeyedLockerOptions
{
    LockIdleCleanupInterval  = TimeSpan.FromSeconds(60),
    LockIdleCleanupThreshold = TimeSpan.FromSeconds(30),
    LongHeldLockThreshold    = TimeSpan.FromMinutes(1),
    DisposeDrainTimeout      = TimeSpan.FromSeconds(10),
};

await using var locker = new AsyncKeyedLocker<string>(logger, options);

What's Under the Hood

Internally, LockIt uses SemaphoreSlim instances per key, stored in a ConcurrentDictionary. The tricky part — safely creating and removing semaphores without races — is handled with reference counting.

It also ships with:

  • Automatic idle cleanup — a background timer removes unused semaphores, keeping memory bounded
  • Long-held lock detection — logs a warning via ILogger when a lock exceeds a threshold
  • Built-in metricsSystem.Diagnostics.Metrics instrumentation for OpenTelemetry: acquisitions, releases, timeouts, active locks, contention wait time

Why I Built It

Honestly? I got tired of copy-pasting the same pattern across projects.

Every time I needed per-key locking, I'd end up writing some variation of a ConcurrentDictionary<TKey, SemaphoreSlim> with reference counting. And every time, I'd have to think carefully about the same edge cases: what if two threads race to create the semaphore for the same key? What if I remove a semaphore just as another thread picks it up?

The logic isn't difficult, but it's easy to get subtly wrong, annoying to test thoroughly, and the cleanup piece almost always gets skipped in the first version.

I also wanted the complete production-grade version: cancellation support, timeouts, proper disposal, metrics, and DI integration. None of the ad-hoc implementations I'd written previously had all of that together.

So instead of writing it a fifth time in the next project, I wrote it once, properly, with a full test suite — and published it.

That's the origin story for most of the packages I've put out. Scratch your own itch, do it properly, share it.

When Not to Use It

LockIt is an in-process lock. It works perfectly for a single application instance.

If you're running multiple instances behind a load balancer and need cross-instance coordination, you need a distributed lock — Redis SETNX, Azure Blob leases, or something like Redlock. LockIt won't help you there.

For single-instance services, or anything that scales to exactly one, it's a clean zero-infrastructure solution.

Links