Skip to main content
SEMastery
Fundamentalsintermediate

Distributed Locking in .NET: Coordinating Work Across Multiple Instances

A friendly beginner guide to distributed locking in .NET. Learn how to stop multiple app instances from doing the same job twice using Redis and SQL Server.

12 min readUpdated April 20, 2026

Imagine your school has one library photocopier. Many students want to use it. If two students press start at the same moment, the paper jams and both copies get ruined. So the librarian keeps a single key. You take the key, make your copies, and hand it back. Only the student holding the key may use the machine. Everyone else waits their turn.

A distributed lock is that single key, but for computer programs. When you run many copies of your app on many servers, sometimes only one copy is allowed to do a certain job. The lock is the key they all share.

In this guide you will learn what distributed locking is, why a normal lock in C# is not enough, and how to do it safely in .NET using Redis or SQL Server. We will keep things simple and use small examples you can follow.

Why one normal lock is not enough

In C# you may already know the lock keyword. It stops two threads inside the same program from touching the same thing at once. That works great when your app runs as a single copy.

But modern apps rarely run as one copy. To handle more users and to survive crashes, we run several instances of the same app at the same time. They might sit behind a load balancer in the cloud. Each instance is its own process with its own memory.

Here is the problem. A normal lock only lives inside one process's memory. Instance A has no idea what Instance B is locking. So both can run the same job together and step on each other.

Each instance has its own memory, so a normal C# lock cannot see the others.

To coordinate across all of them, the lock must live outside every instance, in one shared place they can all reach. That shared place is usually Redis or a database like SQL Server.

A real job that needs a distributed lock

Let us say your app sends a daily email summary to every user at 9:00 AM. A background timer fires the job. Simple, right?

Now you run three instances. At 9:00 AM, all three timers fire. Without coordination, every user gets the email three times. Customers complain. You look bad.

With a distributed lock, the three instances race to grab one shared key. Only the winner sends the emails. The other two see the key is taken and quietly skip the job.

Daily email job with a distributed lock

Timer fires
Try lock
Winner works
Others skip

Steps

1

Timer fires

9 AM on all 3 instances

2

Try lock

All race for one key

3

Winner works

Sends the emails once

4

Others skip

Lock busy, do nothing

Three instances race; only one wins the key and does the work.

What a good distributed lock must do

A distributed lock is more than a shared flag. To be safe it should promise a few things.

PromiseWhat it meansWhy it matters
Mutual exclusionOnly one holder at a timeStops double work and data corruption
Deadlock-freeThe lock always frees eventuallyA crashed holder must not block forever
Fault tolerantWorks even if a server diesYour app must keep running
Auto-expiry (TTL)Lock releases after a time limitProtects you if the holder crashes mid-job

That last one, TTL (time to live), is very important. If the holder crashes without releasing the lock, the lock would be stuck forever. So we give every lock a deadline. After the deadline passes, the lock frees itself automatically.

The easy way: the DistributedLock library

You could build all of this by hand, but it is full of tricky edge cases. The community has a well-loved free library called DistributedLock by Michael Adelson. It gives you locks backed by Redis, SQL Server, PostgreSQL, Azure Blob Storage, and more, all with the same simple shape.

Install the backend you want from NuGet. For SQL Server:

dotnet add package DistributedLock.SqlServer

The core pattern is the same no matter which backend you choose. You acquire the lock, do your work inside a using block, and the lock releases when the block ends.

using Medallion.Threading.SqlServer;
 
// "DailyEmailJob" is the name of the shared key.
var connectionString = "Server=.;Database=App;Trusted_Connection=True;";
var myLock = new SqlDistributedLock("DailyEmailJob", connectionString);
 
// Try to grab the lock. Wait up to 5 seconds, else give up.
await using (var handle = await myLock.TryAcquireAsync(TimeSpan.FromSeconds(5)))
{
    if (handle is null)
    {
        // Someone else holds it. We skip the job.
        Console.WriteLine("Another instance is already sending emails.");
        return;
    }
 
    // We hold the lock here. Safe to do the work.
    await SendDailyEmailsAsync();
}
// The lock is released automatically when the using block ends.

Notice the difference between two methods. AcquireAsync waits until the lock is free. TryAcquireAsync waits only for a short time, then returns null if it could not get the lock. For a "run once" job, TryAcquireAsync is perfect because the losers should simply skip, not queue up.

How TryAcquire behaves: the winner works, the loser skips.

Using it cleanly with dependency injection

In a real ASP.NET Core app you do not want to pass connection strings around everywhere. The library gives you IDistributedLockProvider, which fits nicely into the built-in DI container. Your code then asks for a lock by name and does not need to know what backend sits behind it.

using Medallion.Threading;
using Medallion.Threading.SqlServer;
 
var builder = WebApplication.CreateBuilder(args);
 
// Register one lock provider for the whole app.
builder.Services.AddSingleton<IDistributedLockProvider>(_ =>
    new SqlDistributedSynchronizationProvider(
        builder.Configuration.GetConnectionString("Default")!));
 
var app = builder.Build();

Now any service can inject IDistributedLockProvider and use it without caring whether it is Redis or SQL Server underneath:

public class ReportService
{
    private readonly IDistributedLockProvider _locks;
 
    public ReportService(IDistributedLockProvider locks) => _locks = locks;
 
    public async Task BuildNightlyReportAsync(CancellationToken ct)
    {
        var myLock = _locks.CreateLock("NightlyReport");
 
        await using var handle = await myLock.TryAcquireAsync(timeout: TimeSpan.Zero, ct);
        if (handle is null)
        {
            // Another instance got there first. Nothing to do.
            return;
        }
 
        await GenerateReportAsync(ct);
    }
}

A nice benefit of this design: if you later move from SQL Server to Redis, you only change the one registration line. The rest of your code stays exactly the same.

How SQL Server locks work under the hood

SQL Server has built-in helpers called sp_getapplock and sp_releaseapplock. These are application-level locks that live inside the database. You give the lock a name, SQL Server remembers who holds it, and it frees the lock when your session ends or your transaction finishes.

This is great because most apps already have a database. There is nothing new to install and the lock is as reliable as your database. The cost is that every lock attempt is a round trip to the database, which is fine for jobs but can be slow if you need thousands of locks per second.

SQL Server application lock lifecycle

Open session
getapplock
Do work
releaseapplock

Steps

1

Open session

Connect to the database

2

getapplock

Reserve the named lock

3

Do work

Run the protected code

4

releaseapplock

Free it for others

The lock lives inside a database session and frees when the session ends.

How Redis locks work: the Redlock idea

Redis is an in-memory data store, so it is very fast. The simplest Redis lock is one SET command with a special flag that only succeeds if the key does not already exist, plus a TTL so the key expires on its own.

In plain terms: "Create this key only if nobody else has, and delete it automatically after 30 seconds." If your SET succeeds, you own the lock. If it fails, someone else owns it.

For a single Redis server that is enough. But what if that one Redis server dies right after granting your lock? To survive that, Redis offers the Redlock algorithm. You run several independent Redis servers (an odd number, often five). You try to grab the lock on all of them. You only count yourself as the winner if a majority say yes.

Redlock: you win only if most of the Redis nodes grant the lock.

Because a majority granted the lock, one node failing does not break you. To use Redis with the DistributedLock library, install DistributedLock.Redis and point it at your Redis connection.

using Medallion.Threading.Redis;
using StackExchange.Redis;
 
var redis = await ConnectionMultiplexer.ConnectAsync("localhost:6379");
var myLock = new RedisDistributedLock("import:orders", redis.GetDatabase());
 
await using var handle = await myLock.TryAcquireAsync();
if (handle is not null)
{
    await ImportOrdersAsync();
}

Choosing a backend

There is no single "best" backend. The right choice depends on what you already run and how fast you need locks.

BackendBest whenWatch out for
SQL ServerYou already have a databaseExtra database round trips
RedisYou need fast locks or already cache in RedisSingle node can lose the lock if it dies
PostgreSQLYour app runs on PostgresSame round-trip cost as SQL Server
Azure BlobYou run in Azure with no DB or RedisHigher latency than the others

A good rule for beginners: pick the thing you already operate. If you run SQL Server, use SQL Server locks. If you run Redis for caching, use Redis. Fewer moving parts means fewer things to break at 2 AM.

The danger of a lock that expires too soon

Here is a subtle trap. Suppose you set a TTL of 30 seconds, but your job sometimes takes 40 seconds. After 30 seconds the lock frees itself. Now a second instance grabs the lock and starts the same job, while the first instance is still running. Two holders at once. The very thing you wanted to prevent.

There are two ways to guard against this.

First, set the TTL generously. A common rule of thumb is to set the TTL to 2 to 3 times how long you expect the work to take. If your job usually takes 10 seconds, a 30 second TTL gives healthy breathing room.

Second, use a fencing token. This is a number that goes up by one every time the lock is granted. When your job writes its result, it includes the token. The resource it writes to remembers the highest token it has seen and rejects anything with an older, smaller number. So even if a slow old holder wakes up and tries to write, its stale token is refused.

A fencing token blocks a stale holder from writing old data.

A short checklist before you ship

Before you put a distributed lock into production, walk through these questions:

  • Does the lock have a TTL so a crash cannot block it forever?
  • Is the TTL comfortably longer than your slowest expected run?
  • Do you handle the "could not get the lock" case gracefully instead of crashing?
  • For losers, do you skip the job rather than pile up waiting?
  • Do you release the lock in a using / await using block so it always frees?
  • For long or critical jobs, did you consider a fencing token?

If you can answer yes to these, you are in good shape.

Common mistakes to avoid

A few traps catch many beginners. Keeping a lock for the whole request when only one small step needs it. This makes everyone wait and kills your throughput. Lock the smallest critical section you can.

Another is forgetting to handle failure. Redis or the database might be briefly unreachable. Wrap your acquire call so a hiccup does not take down the request. A simple retry with a short, growing delay (called exponential backoff) avoids a stampede where every instance retries at the same instant.

The last one is treating a distributed lock as a perfect guarantee. Networks are messy and clocks drift. For most jobs a good lock is plenty. For money-critical operations, add a fencing token or design the operation so running it twice is harmless (this is called being idempotent), which is the strongest safety net of all.

Quick recap

  • A distributed lock is a shared key that many app instances can see, so only one does a protected job at a time.
  • A normal C# lock only works inside one process, so it cannot coordinate copies on different servers.
  • Put the lock in a shared place: Redis, SQL Server, PostgreSQL, or Azure Blob.
  • The DistributedLock library gives all backends the same simple shape: acquire in a using block and it frees automatically.
  • Use TryAcquireAsync for run-once jobs so losers skip instead of queue.
  • Always give a lock a TTL so a crash cannot block it forever, and make the TTL longer than your slowest run.
  • For long or critical work, add a fencing token or make the job idempotent so doing it twice is safe.
  • Pick the backend you already operate. Fewer moving parts is better.

References and further reading

Related Posts