I shipped a Redis distributed lock that still let two customers buy the last item in stock — here’s how I rebuilt it correctly and the race conditions I had to eliminate along the way.
Last updated: July 4, 2026
A few months into running inventory checks for a mid-sized e-commerce checkout flow, I got a bug report that seemed impossible: two customers had both successfully purchased the very last unit of a limited-edition product. I had a Redis lock in place already. It just wasn’t correct. Debugging that incident taught me more about distributed locking than any tutorial had, and it’s the reason I now treat naive SETNX locks as a liability rather than a solution.
TL;DR
- A basic
SET key value NXlock in Redis is not safe on its own — you need an expiry, a unique lock value, and a safe release script to avoid race conditions. - The most common e-commerce failure mode is a lock expiring mid-operation, letting a second process acquire it while the first is still running.
- For anything business-critical (payments, inventory decrement), consider the Redlock algorithm or move to a database-level constraint as a backstop.
Why This Matters for E-Commerce
Distributed locks in e-commerce systems typically protect inventory decrements, payment idempotency, and flash-sale checkout flows. Get the locking wrong and you get overselling, double charges, or two workers processing the same order — all of which cost real money and erode customer trust.
The tricky part is that Redis locks look deceptively simple to implement and deceptively easy to get subtly wrong. A lock that works fine in your staging environment under low concurrency can fail silently in production the moment you hit real traffic with network jitter and GC pauses.
[SOURCE: https://redis.io/docs/latest/develop/use/patterns/distributed-locks/]
Prerequisites
- Redis 6.2+ (I’m using
redis-cliexamples throughout, but the same commands apply through any client library) - A basic understanding of your application’s concurrency model — how many workers or pods can attempt the same lock simultaneously
- Familiarity with Lua scripting in Redis, since safe lock release requires it
- Node.js or Python for the code examples (I’ll show both patterns conceptually)
Step-by-Step Implementation
Step 1: Never use a plain SETNX without an expiry
This is the version I inherited, and it’s the root cause of my incident:
SETNX lock:product:8842 "locked"
If the process holding this lock crashes or gets OOM-killed before it calls DEL, the lock is held forever, and every future checkout attempt for that product blocks indefinitely.
Step 2: Set the lock atomically with an expiry and a unique token
SET lock:product:8842 "worker-a1b2c3" NX PX 5000
NX— only set if the key doesn’t already existPX 5000— auto-expire after 5000ms so a crashed worker can’t hold the lock forever- The value
worker-a1b2c3is a unique identifier (I use a UUID per lock attempt) — this is critical for the release step
Security Note: Never use a static string like
"locked"as the lock value. Without a unique token, any process can accidentally release a lock it doesn’t own, including one held by a completely different request.
Step 3: Release the lock safely with a Lua script
This is the step almost everyone gets wrong, including me on my first attempt. A naive release looks like this:
DEL lock:product:8842
The problem: if your lock expired due to a slow operation and another worker acquired it in the meantime, this DEL will delete their lock, not yours. The fix is to check-and-delete atomically:
-- release.lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
Call it with:
EVAL "$(cat release.lua)" 1 lock:product:8842 worker-a1b2c3
This only deletes the key if the value still matches your worker’s unique token, which is exactly what stops the race condition I hit in production.
Step 4: Add lock extension for long-running operations
If your operation might legitimately take longer than the TTL (e.g. calling a slow payment gateway), extend the lock instead of setting an unrealistically long expiry:
-- extend.lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
I call this every 2 seconds from a background heartbeat while the critical section is still running, similar to how Redlock’s client libraries handle auto-renewal.
Step 5: Wrap the inventory check inside the lock, not around it
This sounds obvious, but I’ve seen it done wrong: the lock has to wrap the read-check-write sequence, not just the write.
lock_token = acquire_lock(f"lock:product:{product_id}", ttl_ms=5000)
if not lock_token:
raise LockAcquisitionError("Could not acquire inventory lock")
try:
stock = redis_client.get(f"stock:{product_id}")
if int(stock) <= 0:
raise OutOfStockError()
redis_client.decr(f"stock:{product_id}")
create_order(product_id, customer_id)
finally:
release_lock(f"lock:product:{product_id}", lock_token)
If you check stock, release the lock, and then decrement, you’ve reopened the exact race condition the lock was supposed to close.
Step 6: Consider Redlock for multi-instance Redis deployments
If you’re running Redis in a way where a single instance failing over could hand out the “same” lock twice (e.g., asynchronous replication), a single-instance lock isn’t enough. The Redlock algorithm acquires the lock across a majority of independent Redis nodes (typically 5) before considering it held, which tolerates a minority of node failures without losing lock safety.
[SOURCE: https://github.com/redis/redis-py]
Pro Tip: Redlock is genuinely debated in the distributed-systems community — Martin Kleppmann published a well-known critique arguing it doesn’t guarantee safety under certain clock and GC-pause scenarios. I use Redlock for reducing duplicate work (e.g., preventing two workers from both sending the same email), but I never rely on it as the sole safety mechanism for financial or inventory correctness.
Step 7: Add a database-level backstop constraint
For anything touching money or stock counts, I don’t trust the lock alone — I also add a real constraint at the database layer:
UPDATE inventory
SET stock = stock - 1
WHERE product_id = 8842 AND stock > 0
RETURNING stock;
If this returns zero rows, the order fails safely even if the Redis lock somehow let two requests through. This single line has saved me more than once when the lock layer had an edge case I hadn’t anticipated.
Real-World Tips I Use in Production
- I always set the lock TTL to roughly 2-3x my p99 operation latency, not a round number I picked arbitrarily — this avoids both premature expiry and unnecessarily long lock hold times.
- I emit a metric every time a lock acquisition fails or a release script returns
0(meaning “not my lock anymore”) — spikes in that metric are an early warning of contention or TTL misconfiguration. - For flash sales, I pre-warm the stock counters into Redis well before the sale starts, since a cold cache combined with lock contention is a recipe for timeouts right when traffic peaks.
[INTERNAL LINK: related article]
Common Errors and How I Fixed Them
Error: orders occasionally processed twice under load Root cause was the lock TTL expiring while an external payment API call was still in flight. Fix: implemented the heartbeat-based lock extension from Step 4.
Error: NOSCRIPT No matching script when calling EVALSHA This happens when Redis restarts or flushes its script cache and you’re calling EVALSHA with a stale SHA. I now always fall back to EVAL with the full script on a NOSCRIPT error, or reload with SCRIPT LOAD on connection init.
Gotcha I discovered the hard way: Under Redis Cluster, a lock key and the stock counter key it protects can end up on different shards if you’re not using hash tags, which breaks the atomicity you’re relying on. I now always use {product:8842} style hash tags on both keys so they’re guaranteed to live on the same shard.
FAQ
Q: How do I prevent race conditions in Redis distributed locks? A: Use an atomic SET NX PX with a unique token per lock holder, release only via a Lua script that checks the token first, and wrap the entire read-check-write sequence inside the lock rather than just the final write.
Q: Is Redis SETNX enough for a distributed lock? A: No — SETNX alone has no expiry, so a crashed process can hold the lock forever; you need the combined SET key value NX PX ttl form instead.
Q: What is the Redlock algorithm and should I use it for e-commerce inventory? A: Redlock acquires a lock across a majority of independent Redis nodes for stronger fault tolerance, but due to documented edge cases around clock drift, I pair it with a database-level constraint rather than trusting it alone for inventory or payment correctness.
Q: How long should a Redis lock TTL be for checkout flows? A: I set it to roughly 2-3x my measured p99 latency for the protected operation, combined with a heartbeat-based extension mechanism for operations that can legitimately run longer.
Q: Why did my Redis lock let two requests through even with NX and an expiry set? A: This usually means the lock expired mid-operation before the first request finished, or the read-check-write logic was only partially wrapped inside the lock — both of which I cover in Steps 3 through 5 above.
Conclusion
Distributed locking in Redis is simple to start and genuinely hard to get fully correct — the difference between my broken first version and the one running in production now came down to unique tokens, atomic Lua-based release, and a database-level backstop I don’t fully trust the lock to replace. If you’ve run into a similar overselling or double-processing bug, I’d love to hear how you tracked it down.
About the Author
I’m a backend engineer with over 8 years of experience building high-concurrency checkout and inventory systems for e-commerce platforms, with a focus on Redis, PostgreSQL, and distributed systems design. I’ve been on the receiving end of more than one 3 AM page caused by a subtly broken lock, which is exactly why I write about the failure modes in detail. Outside of work, I contribute to open-source tooling around Redis client libraries.
