As applications scale horizontally, multiple instances often process the same jobs simultaneously. Without proper coordination, duplicate processing can occur, leading to inconsistent data, duplicate emails, multiple payment attempts, or conflicting updates.

Distributed locking solves this problem by ensuring that only one application instance can execute a critical section at a time. Redis is a popular choice for implementing distributed locks because of its speed, atomic operations, and broad adoption in cloud-native applications.

In this article, you'll learn how to implement distributed locking with Redis in .NET 11, explore common use cases, and understand how to validate lock behavior using a structured testing methodology.

Note: This article focuses on implementation patterns and testing methodology. It does not include fabricated benchmark results.

Why Distributed Locking Matters

Consider an application running on multiple servers.

             Load Balancer
                  │
        ┌─────────┴─────────┐
        ▼                   ▼
 ASP.NET Core App 1   ASP.NET Core App 2
        │                   │
        └─────────┬─────────┘
                  ▼
          Background Job

Without coordination, both instances may execute the same job simultaneously.

Possible consequences include:

A distributed lock ensures only one instance performs the operation.

When Should You Use Distributed Locks?

Common scenarios include:

Avoid using distributed locks for ordinary CRUD operations where optimistic concurrency or database transactions are sufficient.

Why Redis?

Redis provides:

The SET command with the NX (Only if Not Exists) and PX (Expiration) options enables atomic lock acquisition.

Create the Project

dotnet new webapi -n DistributedLockDemo

Install the Redis client.

dotnet add package StackExchange.Redis

Configure Redis

builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
    ConnectionMultiplexer.Connect("localhost:6379"));

This registers a shared Redis connection for the application.

Acquire a Lock

Create a service.

using StackExchange.Redis;

public class DistributedLockService
{
    private readonly IDatabase _database;

    public DistributedLockService(
        IConnectionMultiplexer redis)
    {
        _database = redis.GetDatabase();
    }

    public async Task<bool> AcquireAsync(
        string key,
        string value,
        TimeSpan expiry)
    {
        return await _database.StringSetAsync(
            key,
            value,
            expiry,
            When.NotExists);
    }
}

When.NotExists ensures the lock is acquired only if it does not already exist.

Release the Lock

A lock should only be released by its owner.

public async Task ReleaseAsync(
    string key,
    string value)
{
    const string script = """
        if redis.call('GET', KEYS[1]) == ARGV[1]
        then
            return redis.call('DEL', KEYS[1])
        end
        return 0
        """;

    await _database.ScriptEvaluateAsync(
        script,
        new RedisKey[] { key },
        new RedisValue[] { value });
}

Comparing the stored value before deletion prevents one process from accidentally releasing another process's lock.

Generate a Unique Lock Identifier

Each lock owner should have a unique identifier.

var lockId = Guid.NewGuid().ToString();

Store this value when acquiring the lock and reuse it during release.

Using the Lock

var acquired = await lockService.AcquireAsync(
    "jobs:daily-report",
    lockId,
    TimeSpan.FromMinutes(2));

if (!acquired)
{
    return Results.Conflict(
        "Job already running.");
}

try
{
    await GenerateReportAsync();
}
finally
{
    await lockService.ReleaseAsync(
        "jobs:daily-report",
        lockId);
}

Always release locks in a finally block.

Lock Expiration

Every distributed lock should have an expiration.

Without expiration:

Example:

TimeSpan.FromMinutes(5)

Choose an expiration slightly longer than the expected execution time.

Background Service Example

public class ReportWorker : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        // Acquire distributed lock

        // Execute scheduled work

        // Release lock
    }
}

Only one worker instance performs the scheduled task across all application instances.

End-to-End Workflow

A typical workflow is:

  1. Application starts.

  2. Background worker begins execution.

  3. Worker attempts to acquire a Redis lock.

  4. Redis grants the lock to one instance.

  5. Winning instance executes the job.

  6. Other instances skip execution.

  7. Job completes.

  8. Lock is released or expires automatically.

This prevents duplicate execution across distributed deployments.

Distributed Locking Alternatives

ApproachSuitable ForDistributed
lock keywordSingle processNo
SemaphoreSlimAsync within one processNo
SQL row lockingDatabase workloadsYes
Redis distributed lockMulti-instance applicationsYes
Leader electionCluster coordinationYes

The C# lock statement only synchronizes threads within a single process and cannot coordinate multiple application instances.

Testing Methodology

Distributed locking should be validated under concurrent execution rather than measured with synthetic benchmark numbers.

Test Environment

Keep these variables consistent:

Test Scenarios

Evaluate:

Metrics to Observe

Collect:

Useful Tools

Useful tools include:

Validate correctness first, then evaluate throughput under realistic concurrency.

Best Practices

Common Mistakes

MistakeImpact
No lock expirationPermanent deadlocks after crashes
Releasing a lock without ownership verificationAnother process's lock may be removed
Holding locks during lengthy operationsReduced throughput
Using distributed locks for every requestUnnecessary complexity
Ignoring Redis failuresInconsistent execution
Assuming locks guarantee business correctnessIncomplete fault tolerance

Troubleshooting

Lock Cannot Be Acquired

Verify:

Duplicate Jobs Still Execute

Review:

Locks Never Release

Check:

FAQs

Why can't I use the C# lock keyword?

The lock keyword only synchronizes threads inside a single application process. It cannot coordinate multiple servers or containers.

Why should locks expire?

Expiration prevents stale locks from blocking future work if an application crashes before releasing the lock.

Is Redis locking suitable for scheduled jobs?

Yes. It is commonly used to ensure only one application instance executes recurring background jobs.

Should distributed locking replace database transactions?

No. Distributed locks coordinate execution across instances, while database transactions ensure consistency within database operations.

Can Redis become a single point of failure?

It can if deployed as a single instance. Production environments commonly use Redis replication, Sentinel, or clustered deployments to improve availability.

Conclusion

Distributed locking is an essential technique for coordinating work across multiple ASP.NET Core application instances. By using Redis atomic operations, unique lock identifiers, and automatic expiration, you can prevent duplicate processing while maintaining scalability and reliability.

When combined with idempotent operations, proper monitoring, and realistic concurrency testing, Redis-based distributed locking provides a robust foundation for production-ready background processing and distributed workflows.