Redis Distributed Lock in Node.js: Prevent Race Conditions

by Fahim

You get hit with two identical webhook requests at the exact same millisecond. Both try to deduct the same user’s wallet balance. If you aren’t careful, they both succeed, and you just doubled their money. Today, we’re building a production-ready Redis distributed lock in Node.js using ioredis to kill these race conditions once and for all.

Redis terminal screen showing distributed lock commands and Lua scripts
Redis terminal screen showing distributed lock commands and Lua scripts

Why Node’s Single Thread Won’t Save You

I used to think that because Node.js runs on a single thread, race conditions were impossible. That’s a dangerous myth. Sure, JavaScript execution is single-threaded, but the moment you await a database query or an external API call, you yield control back to the Node.js event loop.

Here’s how it breaks in real life with two concurrent requests, A and B, trying to update a user’s balance:

  • Request A queries the database: “What is the user’s balance?” (Database returns $10)
  • Request A awaits the response, yielding the thread.
  • Request B immediately jumps in and queries: “What is the user’s balance?” (Database also gets $10)
  • Request A calculates the new balance ($10 – $5 = $5) and saves it.
  • Request B calculates the new balance ($10 – $5 = $5) and saves it.

The user just spent $10, but their balance is still $5. You just lost money. If you’ve dealt with this in other environments, you might have used built-in tools like we did when we wrote about how to prevent race conditions in Google Apps Script. But in a distributed Node.js environment, we need a centralized orchestrator. Enter Redis.

The Setup: Spin Up Redis and Node

Let’s spin up a quick local environment. I like using Docker for Redis so I don’t clutter my local machine.

Run this command in your terminal to get Redis up:

docker run -d --name redis-lock-test -p 6379:6379 redis:7-alpine

Next, initialize a new Node project and grab our dependencies. We’re using ioredis because its Promise support and Lua scripting integration are top-tier.

mkdir redis-lock-node
cd redis-lock-node
npm init -y
npm install ioredis uuid

Let’s create an index.js file to establish our Redis connection:

const Redis = require('ioredis'); const redis = new Redis({ host: '127.0.0.1', port: 6379, maxRetriesPerRequest: null
}); redis.on('connect', () => console.log('Connected to Redis successfully.'));
redis.on('error', (err) => console.error('Redis connection error:', err));

Run it to make sure you’re connected. Once that’s working, we can build the lock.

The Naive Lock (And Why It Fails)

The first instinct is usually to use Redis's SETNX (Set if Not Exists) command. It sounds perfect: if the key isn't there, set it and return success. If it is there, fail.

Here’s what that naive implementation looks like:

async function acquireNaiveLock(lockKey, ttl) { const result = await redis.set(lockKey, 'locked', 'NX', 'PX', ttl); return result === 'OK';
} async function releaseNaiveLock(lockKey) { await redis.del(lockKey);
}

This looks fine on paper, but it has a massive flaw. Say Process A acquires the lock with a 1000ms TTL. Then, Process A gets hit with a slow network call or CPU spike and takes 1500ms to finish.

At 1000ms, Redis expires the key. Process B immediately jumps in and acquires the lock. At 1500ms, Process A finally finishes and calls releaseNaiveLock, which blindly deletes the key. But wait—Process A just deleted Process B’s lock! Now Process C can slide in, and your concurrency safety is completely shot.

Building a Safe Distributed Lock with Lua

To fix this, we have to make sure a process can only release a lock if it actually owns it. We do this by assigning a unique UUID token to each lock request.

When releasing, we check if the value stored in Redis matches our unique token. If it matches, we delete it. If not, we leave it alone.

But this check-and-delete step has to be atomic. If we run these as two separate Node commands, another process could slip in between the check and the delete. To guarantee atomicity, we use a Redis Lua script. Redis runs Lua scripts in a single thread, meaning no other command can interrupt it mid-execution.

Here’s the Lua script we’ll use to safely release the lock:

const releaseScript = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end
`;

This is the exact atomic pattern recommended in the official Redis Distributed Locks documentation.

Implementing a Lock Manager Class

Let’s wrap this up into a clean, reusable RedisLock class. It’ll handle acquiring the lock, retrying with a backoff if it’s busy, and releasing it safely via our Lua script.

Create a RedisLock.js file and add this:

const { v4: uuidv4 } = require('uuid'); class RedisLock { constructor(redisClient, key, options = {}) { this.redis = redisClient; this.key = `lock:${key}`; this.ttl = options.ttl || 5000; // Time to live in ms this.retryDelay = options.retryDelay || 50; // Delay between retries in ms this.maxRetries = options.maxRetries || 10; // Max retry attempts this.token = null; } async acquire() { const token = uuidv4(); let attempts = 0; while (attempts < this.maxRetries) { const result = await this.redis.set(this.key, token, 'NX', 'PX', this.ttl); if (result === 'OK') { this.token = token; return true; } attempts++; // Add small random jitter to avoid thundering herd problem const jitter = Math.floor(Math.random() * 20); await new Promise((resolve) => setTimeout(resolve, this.retryDelay + jitter)); } return false; } async release() { if (!this.token) { return false; } const releaseScript = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end `; const result = await this.redis.eval(releaseScript, 1, this.key, this.token); this.token = null; return result === 1; }
} module.exports = RedisLock;

Notice the random jitter in the retry delay. This is . It stops blocked processes from retrying at the exact same millisecond, preventing a thundering herd problem on your database and Redis.

Testing the Lock Against a Real Race Condition

Let’s write a simulation to prove this actually works. We’ll simulate a bank transfer that reads a balance, waits 200ms (simulating a slow DB query), and writes it back. We’ll fire off 5 of these concurrently.

Create a test.js file:

const Redis = require('ioredis');
const RedisLock = require('./RedisLock'); const redis = new Redis({ host: '127.0.0.1', port: 6379 }); let mockDatabaseBalance = 100; async function unsafeTransfer(amount) { console.log(`[Unsafe] Starting transfer of $${amount}...`); const currentBalance = mockDatabaseBalance; // Simulate network/database delay await new Promise((resolve) => setTimeout(resolve, 200)); mockDatabaseBalance = currentBalance - amount; console.log(`[Unsafe] Finished transfer. New balance: $${mockDatabaseBalance}`);
} async function safeTransfer(amount) { const lock = new RedisLock(redis, 'user_balance_123', { ttl: 3000, maxRetries: 20, retryDelay: 100 }); console.log(`[Safe] Attempting to acquire lock for $${amount}...`); const acquired = await lock.acquire(); if (!acquired) { console.log(`[Safe] Failed to acquire lock for $${amount}. Aborting.`); return; } try { console.log(`[Safe] Lock acquired! Processing transfer of $${amount}...`); const currentBalance = mockDatabaseBalance; await new Promise((resolve) => setTimeout(resolve, 200)); mockDatabaseBalance = currentBalance - amount; console.log(`[Safe] Finished transfer. New balance: $${mockDatabaseBalance}`); } finally { await lock.release(); console.log(`[Safe] Lock released for $${amount}.`); }
} async function run() { // 1. Run the unsafe concurrent transfers console.log('--- RUNNING UNSAFE TRANSFERS CONCURRENTLY ---'); await Promise.all([ unsafeTransfer(10), unsafeTransfer(10), unsafeTransfer(10) ]); console.log(`Final Unsafe Balance: $${mockDatabaseBalance} (Expected $70, but got wrong value due to race condition)n`); // Reset balance mockDatabaseBalance = 100; // 2. Run the safe concurrent transfers with our Redis Lock console.log('--- RUNNING SAFE TRANSFERS CONCURRENTLY ---'); await Promise.all([ safeTransfer(10), safeTransfer(10), safeTransfer(10) ]); console.log(`Final Safe Balance: $${mockDatabaseBalance} (Expected $70, and got $70 successfully!)`); redis.disconnect();
} run();

Run it with node test.js. You’ll see the unsafe transfers overwrite each other, leaving the balance at $90 instead of $70. The safe, locked transfers queue up beautifully, executing one by one, and output the correct $70 balance.

When to Use Redlock (Multi-Node Redis)

This solution is perfect if you’re running a single Redis instance or a primary-replica setup. But if you’re running a fully distributed Redis cluster with multiple master nodes, a single-node lock can fail if the master crashes before replicating the lock to its replicas.

For multi-node setups, you need the Redlock algorithm. Redlock works by acquiring the lock across multiple independent Redis instances simultaneously. It only succeeds if it gets a majority (e.g., 3 out of 5 nodes).

If you scale to that level, don’t write Redlock from scratch. Use a battle-tested library like redlock on npm. But for 95% of applications running on a single Redis instance, our custom Lua-based lock is faster, lighter, and much easier to debug.

Frequently Asked Questions

Why not just use a database transaction?

Transactions (like SELECT FOR UPDATE) work, but they hold expensive database connections open. If your code has to call an external API or do heavy CPU work while holding that transaction, you’ll quickly exhaust your DB connection pool. Offloading the lock to Redis memory keeps your database fast and breathing room wide open.

What happens if the Node process crashes while holding a lock?

Since we set a TTL when acquiring the lock, Redis will automatically delete the key when it expires. Even if your Node process crashes mid-execution, the lock will naturally release itself in a few seconds, preventing a permanent deadlock.

How do I choose the right TTL for my lock?

Make it long enough to cover your worst-case execution time, plus a safety buffer. If a DB write normally takes 100ms, a 2000ms TTL is a safe bet. Don’t make it unnecessarily long, or crashed processes will block subsequent requests for too long.

Can I use this lock implementation inside Express middleware?

Absolutely. You can wrap this in an Express middleware to block concurrent requests to specific endpoints based on a user’s ID or IP. If you want to see a full implementation of this, check out our guide on how to build a Redis-backed idempotency middleware for Express.

Next Steps

You now have a production-grade distributed lock running in Node. This pattern is a lifesaver for webhooks, financial transactions, and inventory updates.

If you’re building high-throughput systems that need to handle rate limits and queues alongside locking, check out our guide on how to handle external API rate limits with BullMQ and Redis to take your backend to the next level.

all_in_one_marketing_tool