# How do I prevent a Redis cache stampede (thundering herd) — mutex lock or XFetch?

> A single-flight lock plus TTL jitter covers most cases; reach for XFetch only when recompute is truly expensive, and add stale-while-revalidate either way.

- Asked: 2026-05-16
- Answered: 2026-05-19
- Asked by: Cem
- Tags: performans, redis, caching
- Source: https://muhammetsafak.com/just-ask/preventing-cache-stampede-with-mutex-locks-or-early-expiration/
- Language: en-US
- Author: Muhammet Şafak

---
**Question:** We cache a high-traffic e-commerce homepage's product list in Redis for 10 minutes. The instant the cache expires, thousands of concurrent requests get a cache miss and all hammer PostgreSQL at once, taking the DB down.

How do I solve this cache stampede at the architecture level with a mutex lock or probabilistic early expiration (XFetch)?


Short answer: the problem isn't the "miss" itself, it's that the key **falls off a cliff at the same instant** and the whole herd hammers the DB at once. The fix is to reduce that moment to a single request and spread the herd over time.

## Short answer

What you're hitting is the classic **thundering herd**: when the TTL expires, not one user but thousands get a cache miss in the same millisecond and fire the same query at PostgreSQL. Hundreds run when one would do, and the DB buckles. I discussed who pushes freshness into a cache in [the edge caching and invalidation question](/just-ask/edge-caching-with-cloudflare-workers-kv-and-invalidation/); the issue here is **how many times** that refresh runs.

## Why

1. **At expiry the cache does the opposite of its job.** Its purpose is to protect the DB, yet it compresses the entire load into one second.

2. **A fixed TTL manufactures synchronization.** Give every key the same 10 minutes and they all fall together; they bring down the house as a group.

3. **Making the user wait behind a recompute is unnecessary.** If you hold a value that was correct a second ago, there's no reason not to serve it.

## What to do

1. **Set up a single-flight mutex lock.** On a miss the first request takes a short Redis lock (`SET NX PX`, with a millisecond TTL); whoever holds it recomputes and fills the cache. So one query hits Postgres, not hundreds. Always put a TTL on the lock so a dying process can't deadlock it forever.

2. **Add jitter to TTLs.** Make the TTL `10min ± a few random minutes` and keys expire at different moments, spreading the pressure naturally. One-line change, big payoff.

3. **Serve-stale-while-revalidate so users don't wait.** When a key expires, keep serving the old value and refresh in the background. This also drops the "waiters" cost of the lock approach to zero.

4. **Move to XFetch when recompute is genuinely expensive.** With probabilistic early expiration each reader, as the TTL nears, randomly decides to refresh early with a probability proportional to the recompute cost, so the herd never forms. More elegant, but subtler to implement and test.

**Bottom line:** if it were me, I'd start with **single-flight lock + TTL jitter** in most cases — together they cover the vast majority of cases, are operationally simple, and are easy to reason about. Reach for XFetch when recompute is genuinely expensive and traffic is genuinely huge. And whichever you pick, add serve-stale-while-revalidate: never make a user wait behind a database query.

## Related Reading

- [Why should I use a Sorted Set for a live leaderboard in Redis?](https://muhammetsafak.com/just-ask/redis-data-types-using-sorted-sets-for-a-leaderboard/) — Just Ask
- [When should I switch to a covering index with INCLUDE to get an index-only scan?](https://muhammetsafak.com/just-ask/switch-covering-index-include-get-index-only-scan/) — Just Ask
- [Should I use chunkById instead of chunk when the same job also updates the rows it iterates?](https://muhammetsafak.com/just-ask/should-i-use-chunkbyid-instead-of-chunk-when-the-same-job/) — Just Ask
