Skip to content

Preventing Cache Stampede with Redis Locks

Core Concept LearningAugust 3, 20269 min read

A Redis SET key value NX EX 5 lock stops a cache stampede in theory, but two implementation details separate a lock that actually works under concurrent load from one that silently fails the exact moment it's needed most: the acquire step must be a single atomic command (not a check-then-set race), and the release step must verify you still own the lock before deleting it — a naive DEL after a slow operation can delete a lock that expired and was already re-acquired by someone else, leaving two processes believing they hold the lock at once. Cache stampede prevention covers the general shape of the problem and compares mutex locks against probabilistic early expiration and stale-while-revalidate; this guide goes one level deeper into the Redis-specific mechanics of implementing the lock itself correctly.

The running example is a trending-posts leaderboard, recomputed from a slow aggregation query, cached with a 30-second TTL and read by a feed endpoint under heavy concurrent traffic. You'll build the atomic acquire, the Lua-scripted safe release, see the specific race condition that breaks a two-command DEL-only release, and the failure mode that appears at higher concurrency: many Redis nodes and lock contention across replicas. For the broader set of distributed-locking options this sits inside, see distributed locking: Redis, ZooKeeper, and etcd and fencing tokens for distributed transactions.

Atomic acquire vs. a check-then-set race
Atomic acquire vs. a check-then-set race

Why the acquire step must be one atomic command

The naive first instinct is to check whether a lock key exists, and if not, set it — two separate Redis commands (EXISTS lock:trending then SET lock:trending 1 EX 5). Under concurrent load, this is a race: two requests can both see EXISTS return false in the gap between the check and the set, and both proceed to set the lock and both believe they're the sole recomputing process — exactly the stampede the lock was meant to prevent, just with fewer participants. Redis's SET key value NX EX seconds is the fix because it's a single atomic command: "set this key only if it doesn't already exist, with an expiry," evaluated as one indivisible operation on Redis's single-threaded command execution, so there is no gap for a second request to slip through.

The EX (or PX for milliseconds) expiry is not optional — it's the safety net for the case where the lock holder crashes or is killed after acquiring the lock but before releasing it. Without an expiry, every subsequent request would wait forever for a lock that will never be released, turning a transient crash into a permanent outage for that cache key. Five seconds is a reasonable starting TTL for a lock guarding a sub-second recompute; size it to comfortably exceed your recompute's expected worst-case duration, not its average.

Quick reference

  • SET key val NX EX 5 is one atomic Redis command — there is no check-then-set gap for a second concurrent request to exploit.
  • The lock's expiry must safely exceed the recompute's worst-case duration, not its average — too short releases the lock mid-recompute and defeats the whole point.
  • NX (only if Not eXists) is what makes this a lock rather than an unconditional overwrite — a plain SET without NX would let every request believe it acquired the lock.
  • This same atomic-acquire principle underlies Redlock and other multi-node Redis locking schemes — see distributed locking for when a single-node lock isn't enough.

Remember this

A lock acquired with a separate check-then-set has a race window a stampede will find under real concurrency — SET NX EX is atomic, and the expiry is the crash-recovery safety net, not an optional detail.

The release bug: deleting a lock you no longer own

A naive release does DEL lock:trending after the recompute finishes — which looks correct until the recompute runs slower than expected. If the recompute takes longer than the lock's TTL, the lock expires on its own, a second request acquires it and starts its own recompute, and then the first request finally finishes and calls DEL — deleting the second request's still-active lock, not its own expired one. Now a third request can acquire the lock while the second request's recompute is still in flight, and you have two or three concurrent recomputes despite the lock existing the whole time — the stampede the lock was supposed to prevent, reintroduced by a release step that doesn't check ownership.

The fix is to tag the lock's value with a unique token per acquirer (a UUID generated at acquire time) and release only if the current value still matches that token — checking and deleting must be one atomic operation, which means a Lua script (Redis evaluates a Lua script atomically, uninterrupted by other commands), not two separate GET then DEL commands, which reintroduces the exact same check-then-act race as the naive acquire.

Acquire, recompute, and safely release the lock
Acquire, recompute, and safely release the lock

Quick reference

  • A Lua script is evaluated atomically by Redis — the GET-compare-DEL sequence inside it cannot be interrupted by another client's command, unlike doing GET then DEL as two separate round trips.
  • Generate a fresh unique token (crypto.randomUUID()) per acquire attempt, never a static value — a static value can't distinguish your lock from a different process's lock on the same key.
  • If the Lua script returns 0, your lock had already expired and been replaced by someone else's — that's expected under this design and not itself an error to alert on.
  • This token-check pattern is a lightweight version of the fencing-token idea — see fencing tokens for distributed transactions for when even this isn't enough (a lock holder that's merely slow, not crashed, but still past its TTL).
Naive release — can delete someone else's lock
1async function withLock(key: string, ttlSeconds: number, fn: () => Promise<void>) {2  const acquired = await redis.set(`lock:${key}`, "1", "NX", "EX", ttlSeconds);3  if (!acquired) return; // someone else holds it4  try {5    await fn();6  } finally {7    await redis.del(`lock:${key}`); // BUG: deletes whoever holds the lock NOW,8                                       // not necessarily this call's own lock9  }10}
Token-checked release — atomic compare-and-delete via Lua
1const UNLOCK_SCRIPT = `2  if redis.call("GET", KEYS[1]) == ARGV[1] then3    return redis.call("DEL", KEYS[1])4  else5    return 06  end7`;8 9async function withLock(key: string, ttlSeconds: number, fn: () => Promise<void>) {10  const token = crypto.randomUUID();11  const acquired = await redis.set(`lock:${key}`, token, "NX", "EX", ttlSeconds);12  if (!acquired) return;13  try {14    await fn();15  } finally {16    // atomically: only delete if the value still matches OUR token17    await redis.eval(UNLOCK_SCRIPT, { keys: [`lock:${key}`], arguments: [token] });18  }19}

Remember this

Release must be an atomic compare-and-delete keyed on a unique per-acquisition token, evaluated in a Lua script — a plain DEL after any operation whose duration isn't tightly bounded by the TTL can delete a different process's active lock.

What everyone else does while one request recomputes

The lock stops duplicate recomputes, but it doesn't answer what the other 4,999 concurrent requests do while one request holds the lock and recomputes. Two reasonable strategies: short-poll and retry — the request that didn't get the lock waits a small fixed delay (50-100ms) and checks the cache again, repeating until the value appears or a max-wait timeout is hit, which adds latency to every waiting request but always returns fresh data. Serve stale while one revalidates — if you keep the previous cached value slightly past its nominal TTL (a stale-while-revalidate pattern), every request that doesn't win the lock gets the stale-but-present value immediately, with zero added latency, at the cost of briefly serving data that's a few seconds old.

For a trending-posts leaderboard, stale-while-revalidate is usually the better trade: a leaderboard that's five seconds stale is imperceptible to users, and the alternative (thousands of requests all adding 50-100ms of polling latency during every cache refresh) is a worse user-facing cost for a nearly-invisible freshness gain. Reserve poll-and-retry for cache keys where the value truly must be current-as-of-now — a live inventory count at checkout, for instance — where a few seconds of staleness is an actual correctness problem, not just a UX nitpick.

Quick reference

  • Poll-and-retry always returns fresh data but adds real latency to every request that loses the lock race — bound the max wait so a stuck recompute doesn't hang callers indefinitely.
  • Stale-while-revalidate adds zero latency for non-lock-holders but means some requests see data that's briefly behind the true current state — fine for most read-heavy, eventually-fresh data.
  • Choose per cache key based on how costly staleness actually is for that specific data — a leaderboard and a live inventory count have very different tolerances even in the same application.
  • Both strategies still need the atomic lock from the previous section underneath them — this section is about what non-lock-holders do, not a replacement for the lock itself.

Remember this

The lock decides who recomputes; a separate decision — poll-and-wait vs. serve-stale — decides what everyone else does in the meantime, and that choice should be made per cache key based on how much staleness actually costs.

The failure that shows up only with Redis replicas

The realistic failure at higher scale: the lock is acquired against a Redis primary, the primary crashes before the SET replicates to its replica, and a failover promotes that replica to primary — which never received the lock write, and now correctly (from its own state) grants the lock to a second request, while the first request is still actively recomputing and believes it holds the lock. This is a genuine limitation of single-key locking on a primary/replica Redis setup: asynchronous replication means a lock acquired an instant before a failover can simply vanish from the new primary's perspective, and no amount of Lua-script correctness on a single node fixes a replication gap between nodes.

For a cache-warming lock where the cost of an occasional double-recompute is "a slightly wasted database query," this failure is usually acceptable — it's rare, and the blast radius is small. For a lock guarding something where two holders acting simultaneously causes real damage (a financial operation, an exclusive resource), a single-node Redis lock is the wrong tool regardless of how carefully you implement acquire/release — that calls for Redlock's multi-node quorum approach, or a consensus system like etcd or ZooKeeper built for exactly this guarantee. Match the tool to the actual cost of double-execution, not to the theoretical possibility of failure.

Zoom: a primary failover can drop a lock that was just acquired
Zoom: a primary failover can drop a lock that was just acquired

Quick reference

  • Async replication means a lock acquired just before a primary failover is not guaranteed to exist on the newly promoted primary — a structural limitation, not an implementation bug.
  • For cache-stampede locks specifically, the cost of a rare double-recompute (one extra database query) is usually acceptable — don't over-engineer this case with a full Redlock deployment.
  • For locks guarding genuinely exclusive, high-cost operations, use a multi-node quorum scheme (Redlock) or a consensus store (etcd, ZooKeeper) instead of a single-node Redis lock.
  • See distributed locking: Redis, ZooKeeper, and etcd for the trade-offs between these options at the point where correctness cost outweighs simplicity.

Remember this

A single-node Redis lock can silently vanish across a primary failover — acceptable risk for a cache-stampede recompute lock, but the wrong guarantee entirely for a lock protecting an operation where two simultaneous holders would cause real damage.

Key takeaway

Implement the withLock helper from the safe-release section against a local Redis instance, wrapping a mock trending-posts recompute that sleeps for 200ms. Fire 50 concurrent requests at the cached endpoint immediately after the cache key expires. Expected result: exactly one request executes the recompute (visible via a counter incremented inside the mock function), and the other 49 either receive the stale cached value (if you implement stale-while-revalidate) or briefly poll until the new value lands. Then break it intentionally — replace the Lua-scripted release with a plain DEL, set the lock TTL shorter than the recompute's sleep duration (say, 100ms TTL vs. 200ms recompute), and rerun the same 50-concurrent-request test. Recovery: watch the recompute counter — with the naive DEL and a too-short TTL, you should see it increment more than once, reproducing the exact double-recompute bug described above. Pass criterion: the token-checked Lua release keeps the recompute counter at exactly 1 under load, and reverting to the naive DEL with a too-short TTL reliably reproduces more than one recompute — proving you've isolated the actual causal bug, not just observed a coincidence.

Share:

Related Articles

A product page's cache key expires at 2:00:00pm. In the same instant, 5,000 concurrent requests check the cache, all get

Read

In-memory caching is an essential component of high-throughput web architectures, reducing database read load and accele

Read

A product page feels instant on the second visit because some layer reused work from the first. The useful beginner ques

Read

Keep learning

Follow a structured path or browse all courses to go deeper.