DEV Community

Cover image for Redis Distributed Locks: The Lock Expired, But the Process Is Still Running
Abhinav
Abhinav

Posted on Edited on

Redis Distributed Locks: The Lock Expired, But the Process Is Still Running

We often use Redis to coordinate work across multiple application instances.

A common solution is a distributed lock.

At first glance, implementing one looks simple:

SET lock:report:123 unique-token NX EX 30
Enter fullscreen mode Exit fullscreen mode

If the key doesn't exist, Redis creates it. If another process already holds the lock, the acquisition fails.

But here's a question that makes distributed locking more interesting:

What happens when the lock expires while the process holding it is still running?

Does Redis stop the process?

Does the process automatically know that it lost ownership?

Can another server start performing the same operation?

Let's explore.
Redis Distributed Locks

1. Why Do We Need Distributed Locks?

Imagine we have a backend service deployed across three application instances.

All three servers receive requests to generate a report for the same customer.

Generating the report involves:

  • Fetching a large amount of data
  • Aggregating the data
  • Generating a PDF
  • Uploading the report to storage

Without coordination, all three servers might generate the same report simultaneously.

This is unnecessary work and could lead to conflicting writes.

We need a mechanism that allows only one participating server to acquire ownership of the resource at a time.

That's where a Redis distributed lock comes in.

2. Acquiring a Lock Atomically

Redis provides a simple way to acquire a lock:

SET lock:report:123 token-A NX EX 30
Enter fullscreen mode Exit fullscreen mode

Let's break this down.

Component Meaning
SET Set a Redis key
lock:report:123 The resource we want to lock
token-A A unique identifier for the lock owner
NX Set the key only if it doesn't already exist
EX 30 Expire the key after 30 seconds

The important part is that Redis executes this as a single atomic command.

Suppose Server A and Server B attempt to acquire the same lock.

Server B tries next:

Server A wins.

Redis stores:

lock:report:123 → token-A
Enter fullscreen mode Exit fullscreen mode

Server B cannot acquire the same lock while the key exists.

So far, everything looks good.

But now let's introduce a failure.

3. The Lock Expires, But the Process Is Still Running

Suppose Server A acquires the lock with a TTL of 30 seconds.

However, generating the report takes 60 seconds.

T0: Server A acquires the lock
    TTL = 30 seconds

T0: Server A starts generating the report

T30: Redis expires the lock

T31: Server B acquires the same lock

T35: Server A is still generating the report
Enter fullscreen mode Exit fullscreen mode

At T31, both servers are executing the same operation.

Server A: Generate report -------------------- Finish
                       |
                       | Lock expires
                       |
Server B:              Generate report -------- Finish
Enter fullscreen mode Exit fullscreen mode

This is the first important lesson:

Redis expiration changes the state of the lock. It does not control the execution of the process.

Redis doesn't:

  • Kill Server A's process
  • Cancel its JavaScript execution
  • Throw an error inside Server A
  • Automatically stop its report generation

Server A is still alive.

It is simply no longer the owner of the lock.

This distinction is fundamental to understanding distributed locks.

4. Redis Locks Are Leases

A Redis lock with a TTL is better understood as a lease.

A lease grants temporary ownership of a resource.

For example:

Server A acquires lock
        |
        v
Ownership valid for 30 seconds
        |
        v
Lock expires
        |
        v
Ownership is no longer valid
Enter fullscreen mode Exit fullscreen mode

The expiration time tells Redis when the lock can become available again.

It does not guarantee that the original process will finish within that time.

This is why choosing a TTL is important.

A TTL that's too short can allow overlapping work.

A TTL that's too long can delay recovery when a process crashes.

We need to think about both cases.

5. A Second Race Condition: The Old Process Deletes a New Lock

Let's consider another failure scenario.

Server A acquires a lock:

SET lock:report:123 token-A NX EX 30
Enter fullscreen mode Exit fullscreen mode

After 30 seconds, the lock expires.

Server B acquires the same lock:

SET lock:report:123 token-B NX EX 30
Enter fullscreen mode Exit fullscreen mode

Now Server B owns the lock.

But Server A eventually finishes its operation and executes:

DEL lock:report:123
Enter fullscreen mode Exit fullscreen mode

What happens?

Redis deletes the key.

Unfortunately, the key now belongs to Server B.

Time     Server A                 Redis                 Server B
-----------------------------------------------------------------
T0       Acquires lock             token-A
T30                                Lock expires
T31                                token-B              Acquires lock
T60       Executes DEL             Key deleted
Enter fullscreen mode Exit fullscreen mode

Server A has accidentally deleted Server B's lock.

This is a different problem from the original overlapping execution.

The first problem was:

Server A continues working after losing ownership.

The second problem is:

Server A deletes a lock that belongs to another process.

Both can happen because the original lock expired.

6. The Solution: Unique Tokens and Atomic Release

When acquiring a lock, we generate a unique token.

const token = crypto.randomUUID();
Enter fullscreen mode Exit fullscreen mode

Redis stores the token as the lock's value.

lock:report:123 → token-A
Enter fullscreen mode Exit fullscreen mode

When Server A releases the lock, it must verify that the stored token still matches its own.

We cannot safely do this:

const value = await redis.get(key);

if (value === token) {
  await redis.del(key);
}
Enter fullscreen mode Exit fullscreen mode

Why?

Because Redis can process another command between GET and DEL.

This is a classic TOCTOU race condition:

Time Of Check To Time Of Use.

Instead, we perform the comparison and deletion atomically using Lua.

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
Enter fullscreen mode Exit fullscreen mode

The script checks whether the current value matches the token.

If it matches, Redis deletes the key.

Otherwise, nothing happens.

This prevents an old process from deleting a newer process's lock.

But notice something important:

This solves the lock-release problem. It does not stop the old process from continuing its work.

7. What If We Renew the Lock?

One solution is to renew the lock periodically while the operation is running.

For example:

Lock TTL = 30 seconds

Server A:
  Acquire lock
       |
       |-- 10 seconds --> Renew lock
       |
       |-- 10 seconds --> Renew lock
       |
       |-- 10 seconds --> Renew lock
       |
       |-- Operation completes
       |
       Release lock
Enter fullscreen mode Exit fullscreen mode

The renewal must also verify ownership.

We cannot blindly execute:

EXPIRE lock:report:123 30
Enter fullscreen mode Exit fullscreen mode

The lock might have expired and been acquired by another server.

A safe renewal uses Lua:

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
    return 0
end
Enter fullscreen mode Exit fullscreen mode

This checks the token and extends the TTL atomically.

If the renewal fails, the process knows it no longer owns the lock.

However, there's a subtle limitation.

Suppose Server A is paused for a long time due to a process stall or a network issue.

Its lock expires.

Server B acquires the lock.

Server A resumes and continues executing.

Even though renewal helps reduce the chance of expiration during normal execution, it cannot guarantee that a stale process will never continue working.

8. Fencing Tokens: Protecting the Resource

For stronger correctness, we need to protect the resource itself.

This is where fencing tokens become useful.

A fencing token is a monotonically increasing number assigned to each lock acquisition.

Server A acquires lock → Token 41

Server B acquires lock → Token 42

Server C acquires lock → Token 43
Enter fullscreen mode Exit fullscreen mode

The resource being protected remembers the highest accepted token.

If Server A attempts to write using token 41 after Server B has already written using token 42, the resource rejects the stale write.

Server A writes with token 41
        |
        v
Resource accepts token 41

Server B writes with token 42
        |
        v
Resource accepts token 42

Server A tries again with token 41
        |
        v
Resource rejects stale token
Enter fullscreen mode Exit fullscreen mode

The important part is that the resource must enforce the fencing token.

Simply generating a number in the application doesn't provide protection.

The validation and write must be implemented atomically at the resource.

This is how we protect against stale lock holders.

9. What Have We Actually Learned?

Let's summarize the different mechanisms.

Mechanism What it protects against
SET NX EX Multiple processes acquiring the same existing lock
TTL Locks remaining indefinitely after a process crashes
Unique token Identifies the lock owner
Atomic compare-and-delete Old processes deleting newer locks
Lock renewal Reduces the chance of a lock expiring during normal work
Fencing tokens Prevents stale lock holders from affecting protected resources, when enforced by those resources

Each mechanism solves a different problem.

A distributed lock is not a single magic command.

It is a coordination mechanism with specific guarantees and limitations.

10. The Mental Model

The most important distinction is between two states.

Redis lock:
Who currently owns the resource?

Application process:
Who is actually executing the work?
Enter fullscreen mode Exit fullscreen mode

These are not the same thing.

A process can still be alive and executing code after its lock expires.

That is the fundamental reason distributed locking is more complicated than setting a Redis key.

When we design a distributed lock, we must consider:

  • How ownership is acquired
  • How ownership expires
  • How ownership is renewed
  • How the lock is safely released
  • What happens when a stale process continues working
  • How the protected resource handles concurrent writes

The next time we write:

SET lock:report:123 token NX EX 30
Enter fullscreen mode Exit fullscreen mode

we should remember:

We have acquired a temporary lease. We have not acquired control over the execution of every other process.

And that distinction is what makes distributed locking interesting.

Top comments (3)

Collapse
 
arslan_ah profile image
Arslan Ahmad •

Section 4 is the crux: a Redis lock is really a lease, and a lease can't stop a paused process (GC pause, slow network, VM hiccup) from carrying on after it expires.

Unique tokens + atomic release via Lua fixes the "old process deletes the new lock" bug, but not the "two holders writing at the same time" one. For that, you need fencing tokens: a monotonically increasing number that the storage layer checks, rejecting any write that carries a stale one.

Martin Kleppmann's "How to do distributed locking" is a great companion read for anyone who wants to go deeper.

Collapse
 
abhivyaktii profile image
Abhinav •

Great recommendation will surely give it a read

Collapse
 
acemeridian profile image
Ace Meridian •

The other half of this failure is eviction, and Redis gives you no signal when it happens to a lock.

I ran 60,000 writes against 8.10.2 with maxmemory 20mb and allkeys-lru: it evicted 45,155 keys and returned OK on every single write. A 3,000-entry list sharing the instance was evicted as one block, silently. A lock key is just another key, so under allkeys-lru the lock can vanish while the holder is still inside the critical section and the next caller acquires it — same double-holder outcome as an expired TTL, with no TTL left to inspect.

If the instance also holds locks, volatile-lru plus a TTL on every cache key keeps the no-TTL lock key out of the eviction pool; a separate instance for locks is stronger. noeviction at least fails loudly: the 15,201st SET raised OOM command not allowed when used memory > 'maxmemory' instead of silently dropping something you rely on.

⌂ 换网址