Cache Stampede Prevention
A product page's cache key expires at 2:00:00pm. In the same instant, 5,000 concurrent requests check the cache, all get a miss, and all 5,000 independently query the database for the identical row — a cache meant to protect the database from load instead multiplies that load by 5,000x, in the same second the database is least prepared for it. This is a cache stampede (also called a thundering herd, or the dogpile effect), and it's one of the few caching bugs that actively gets worse the more effective your cache normally is, because a highly effective cache means the database has forgotten how to handle that query's real traffic.
This guide names exactly what triggers a stampede, then covers the four real mitigations in order of how much they change your system's behavior: mutex-locked recomputation, probabilistic early expiration, and stale-while-revalidate — tracing one stampede that took down a database to see which combination actually would have stopped it. For cache layers and eviction policy generally, see Caching 101; for invalidation patterns, see Caching Strategies.
What Triggers a Cache Stampede
The mechanism is always the same shape: a key that many concurrent requests need becomes a miss for all of them within the same narrow window, and each one independently does the expensive recompute — hitting the database, calling the origin API, running the aggregation — instead of one request doing the work and the rest reusing it. The multiplier is exactly the number of concurrent requests that missed in that window, which is precisely largest for your hottest keys, the ones a cache exists to protect.
Three situations produce this reliably. A hot key's TTL expires under sustained concurrent load — the most common case, and the one that scales with traffic, so it gets worse exactly when you can least afford it. A cold cache after a restart or deploy — every key is a miss simultaneously, and the database absorbs the full unfiltered traffic pattern the cache was hiding. A cache node failure or flush — the same cold-start effect, but unplanned and often during an incident that's already stressing the system.
Quick reference
- The stampede multiplier equals concurrent requests missing in the same window — worst for the hottest keys.
- TTL expiry under load is the recurring case; cold start and cache flush are the acute, one-time cases.
- A cache that's very effective at reducing normal load makes the database less prepared for a stampede's full unfiltered traffic, not more.
- Jittered TTLs (randomizing expiry within a window instead of a fixed value) prevent many keys populated together from expiring in the same instant later — a cheap first line of defense.
- None of this requires unusual traffic — a stampede is a normal concurrency pattern hitting a normal cache-expiry boundary.
Remember this
A cache stampede multiplies origin load by however many requests missed in the same narrow window — which is largest for exactly the hottest keys a cache exists to protect.
Mutex Locks and Request Coalescing
The direct fix: when a request misses the cache, it first tries to acquire a short-lived lock for that key (SETNX in Redis, or an equivalent atomic "set if not exists"). Only the request that wins the lock actually queries the database and repopulates the cache; every other concurrent request either waits briefly and then reads the now-populated cache, or is told to retry shortly. The database sees one query per stampede window instead of one per concurrent request, regardless of how many thousands were waiting.
The lock needs its own short TTL as a safety net — if the lock holder crashes after acquiring it but before populating the cache, a lock with no expiry would leave every subsequent request waiting forever for a value that will never arrive. In-process, this is the same idea as Go's singleflight package: identical concurrent calls collapse into one in-flight execution that all callers share the result of, without even needing a distributed lock when all the traffic lands on one process.
Quick reference
- SETNX (or SET ... NX) is the atomic primitive — only one caller can win the lock.
- The lock's own TTL is a safety net against a crashed lock-holder leaving everyone else waiting forever.
- Losers of the lock should wait-and-retry with a bound, not spin unboundedly or recurse without a depth limit.
- In-process request coalescing (Go's singleflight, or an equivalent promise-cache pattern) solves the same problem without Redis when all traffic lands on one process.
- This fixes the multiplier but adds latency for waiting requests — the value only fully arrives once, not once-per-request.
Remember this
A short-lived distributed lock turns N concurrent cache misses into exactly one database query — the lock's own TTL is what prevents a crashed lock-holder from stranding every other waiter.
Probabilistic Early Expiration
Instead of every request treating a key as valid right up until the exact expiry instant then all missing together, probabilistic early expiration has each read compute a small, growing probability of proactively recomputing before expiry — weighted by how expensive the last recomputation was. A cheap-to-compute value might only trigger early refresh in its final second; an expensive aggregation might start probabilistically refreshing well before its TTL ends. Because the probability triggers independently per request, refreshes spread out across many small, low-probability events instead of colliding in one instant when the TTL hits zero.
The well-known form of this (sometimes called XFetch) uses the last recomputation's duration as the weight: now + delta * beta * ln(random()) compared against the stored expiry, where delta is how long the last recompute took and beta tunes how aggressively to front-load the risk. The practical effect: for a hot key, one request refreshes it slightly early, well before the herd would have hit a hard miss — most of the time, no request ever sees a stampede-triggering miss at all.
Quick reference
- Refresh probability grows as the key approaches its real expiry — most requests still just read the cached value.
- Weighting by last recompute duration means expensive-to-rebuild keys start refreshing earlier than cheap ones.
- The randomness is what spreads refreshes across many small events instead of synchronizing them at exactly TTL=0.
- This works best when you can tolerate the small added cost of occasional early recomputation for the benefit of near-zero stampede misses.
- Combine with jittered base TTLs — early expiration smooths one key's refresh timing; jitter smooths many keys' initial expiry alignment.
Remember this
Probabilistic early expiration trades a small, spread-out cost — occasional early recomputation — for eliminating the synchronized miss that a hard TTL boundary otherwise guarantees under load.
Stale-While-Revalidate
Stale-while-revalidate takes a different trade: instead of preventing the miss, it removes the cost of the miss from the client's critical path entirely. When a key expires, every concurrent request is served the stale (already-expired) value immediately — no one waits on the database — while exactly one background request refreshes the cache for the next round of readers. Requests that need strict freshness can't use this; requests that can tolerate a brief staleness window get both zero added latency and zero stampede.
HTTP has this built in: Cache-Control: max-age=60, stale-while-revalidate=30 tells a compliant cache to serve the cached response for up to 30 seconds past its 60-second freshness window while it revalidates in the background, and CDNs implement this natively. The same idea applies inside an application-level cache: keep the expired value around for a grace period, serve it while one request (using the same mutex-lock pattern above) refreshes it, and only fall through to a genuine blocking database call if the grace period has also elapsed.
Quick reference
- Stale value is served immediately to every request during the revalidation window — no one blocks on the database.
- Exactly one background request performs the actual refresh, using the same single-flight or lock pattern as the mutex approach.
- HTTP's Cache-Control: stale-while-revalidate is a standardized, CDN-native form of this exact pattern.
- Only appropriate when a brief staleness window (the revalidation period) is an acceptable trade for the requesting use case.
- Combine with a hard fallback: if the grace period also elapses with no successful refresh, fall through to a genuine blocking read rather than serving indefinitely stale data.
Remember this
Stale-while-revalidate removes the stampede's latency cost from every request by serving the stale value immediately and refreshing once in the background — the trade is a bounded window of intentional staleness.
Recover One Real Stampede End to End
Trigger. A flash-sale product page's cache entry, read by roughly 5,000 requests per second at peak, expires at exactly 2:00:00pm with no coalescing in place. Symptom. In the same second, the database sees a spike from its normal near-zero direct load for that row to 5,000 concurrent identical queries, exhausts its connection pool, and every other tenant sharing that database starts timing out too — a caching bug becomes a database-wide incident.
Root mechanism. The cache had no mechanism to ensure only one request repopulates an expired key — every miss independently treated itself as the first and only request needing to recompute. Recovery: add mutex-locked recomputation immediately (fastest to deploy, direct fix for the multiplier) and confirm via connection-pool metrics that concurrent queries for that row drop from thousands to one during the next expiry. Verification: replay the same traffic pattern against a staging environment with the lock in place and confirm the database sees exactly one query per expiry window regardless of concurrent request count. Prevention: layer in stale-while-revalidate for this specific hot key so future expirations never even produce a visible miss to the client, and add jittered TTLs across all product-page cache entries so the next flash sale's keys don't all expire in the same synchronized instant.
Quick reference
- Trigger: a hot cache key expires under high concurrent load with no coalescing mechanism.
- Symptom: the database sees a load spike proportional to concurrent request count, not to actual unique work.
- Root mechanism: every miss independently assumed it was the only request needing to recompute the value.
- Recovery: mutex-locked recomputation is the fastest deploy; verify via a connection-pool query-count metric, not just 'the site is back up.'
- Prevention: stale-while-revalidate for hot keys plus jittered TTLs catalog-wide close both the acute incident and the recurring risk.
Remember this
A stampede incident is recovered by making exactly one request responsible for the recompute — verified by watching the database's concurrent-query count during the next expiry, not just by the outage ending.
Key takeaway
A cache stampede is a normal concurrency pattern — many requests missing the same key in the same window — colliding with a normal cache-expiry boundary, and it multiplies load by exactly how many requests were waiting. Mutex-locked recomputation caps that multiplier at one query per window. Probabilistic early expiration spreads refreshes out so the hard miss rarely happens at all. Stale-while-revalidate removes the latency cost of the miss entirely for requests that can tolerate brief staleness.
Practice (25 min): simulate 100 concurrent requests against a cache-aside function with an artificial 200ms database delay and confirm all 100 hit the database when a key is cold. Add mutex-locked recomputation with Redis SETNX and confirm the same 100 concurrent requests produce exactly one database call. Then add a stale-while-revalidate window and confirm requests during that window return instantly with no blocking at all. Pass when you can show, for the same 100-concurrent-request scenario, the exact database query count under no protection, under the mutex lock, and under SWR.
Related Articles
Explore this topic