system-design

The Thundering Herd and Cache Stampedes

The moment a popular cache entry expires, thousands of requests all miss simultaneously and stampede the database at once. A self-inflicted outage that appears exactly when traffic is highest.

Prerequisites: Caching, Retries & Timeouts Time to read: ~14 minutes


The problem

A thundering herd is when many clients all do the same expensive thing at the same instant, overwhelming a resource that could easily handle them if they were spread out.

🚨 The classic case — the cache stampede:

A hot key is cached, serving 10,000 requests/second from Redis. The database is idle.
The key's TTL expires.
Now all 10,000 concurrent requests MISS simultaneously.
All 10,000 hit the database at once — for a key the database was sized to serve 0 times/second.
The database falls over.
When it recovers and repopulates the cache... the TTL expires again later, and it repeats.

🚨 The cruel irony: the stampede hits hardest for the most popular keys (most concurrent requests at expiry) and at peak traffic (most requests in flight). The cache was protecting the database perfectly — until the one instant it didn’t.


The variations

Thundering herds appear in several forms:

The common shape: synchronized demand hitting a resource all at once. The fixes all break the synchronization or collapse the duplicate work.


The fixes

🚨 The core techniques — know these, they’re common follow-ups:

1. Request coalescing / single-flight — the key fix for stampedes

🚨 When many requests miss the same key simultaneously, only one recomputes; the rest wait for its result. One database query instead of 10,000.

10,000 requests miss "hot_key" at once
→ the first acquires a lock, computes the value, populates the cache
→ the other 9,999 wait for that result (or briefly serve stale)
→ database sees 1 query, not 10,000

This is Go’s singleflight, and the standard stampede fix. 🚨 The single most important technique here.

2. Jittered TTLs — prevent avalanches

🚨 Add randomness to expiry times so keys don’t all expire at the same instant:

❌ TTL = 300s  → keys created together all expire together → avalanche
✅ TTL = 300s + random(0, 60s)  → expiry spreads out → no synchronized miss

📐 One line of code, and it prevents the avalanche entirely. The cheapest, most valuable fix — and a frequent interview point.

3. Stale-while-revalidate — never miss on a hot key

🚨 Serve the stale value immediately while refreshing in the background. The expiring key is refreshed by one background task before/as it expires, and users never experience a miss — they get the slightly-stale value instantly.

✅ Users never wait for a cache miss on hot keys; database load is smooth (one refresh, not a stampede). Built into HTTP (stale-while-revalidate). → HTTP caching

4. Probabilistic early expiration

🚨 Refresh a key before it expires, with increasing probability as expiry approaches — so one unlucky request refreshes early and the rest never miss. Avoids the synchronized-miss instant entirely.

if now - value.computed_at > ttl - beta * abs(log(random())):
    refresh()      # one request refreshes early; others keep using the cached value

5. For retry/reconnection storms: backoff + jitter

🚨 Exponential backoff with jitter so retries/reconnections spread out instead of synchronizing. → Retries & Timeouts. The jitter is the essential part — backoff alone leaves them synchronized.

6. Circuit breaker / load shedding — the backstop

If a stampede does hit, a circuit breaker in front of the database fails fast rather than letting the stampede pile up and cause a cascade, and load shedding keeps the database serving some traffic.


🚨 Different from a stampede but worth knowing (caching): requests for keys that don’t exist anywhere miss the cache and the database (nothing to cache). An attacker requesting random non-existent IDs bypasses the cache entirely, hammering the database.

Fix: cache the negative result (cache “not found” with a short TTL), or a Bloom filter to reject definitely-missing keys before touching anything.


The unifying principle

🚨 Every thundering herd is synchronized demand; every fix either de-synchronizes it (jitter, backoff) or collapses the duplicate work (coalescing, stale-while-revalidate). When you see “everyone does X at the same instant,” reach for one of those two.

🎙️ “The cache stampede fix is request coalescing — when a hot key expires, one request recomputes and the rest wait, so the database sees one query not ten thousand. And jittered TTLs so keys don’t all expire together in an avalanche — that one line prevents a whole class of outage. For retry storms, backoff with jitter to de-synchronize.”


⚖️ Trade-offs

Fix Gain Cost
Request coalescing Collapses the stampede to 1 query Waiters block briefly; lock complexity
Jittered TTLs Prevents avalanche, one line Slightly variable freshness
Stale-while-revalidate Users never miss on hot keys Serves slightly-stale data
Probabilistic early expiry No synchronized miss Some early refreshes (minor waste)
Backoff + jitter De-synchronizes retries/reconnects Slightly slower recovery
Circuit breaker / shedding Backstop against cascade Some requests fail fast
Negative caching / Bloom filter Stops penetration Extra structure

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

1. Cause a cache stampede. Cache-aside with a 10-second TTL, load-tested at 500 concurrent requests for one hot key. Watch the database QPS spike every 10 seconds like a heartbeat as the key expires and everyone misses at once. This is the single most instructive caching exercise.

2. Fix it with coalescing. Add single-flight (one request computes, others wait). Watch the database spikes flatten to one query per expiry.

3. Prevent an avalanche with jitter. Populate 1,000 keys with identical TTLs, then observe the database spike when they all expire together. Add ttl + random(0, 60) and watch the expiry spread out smoothly. One line, whole class of outage gone.

4. Simulate a reconnection storm. Hold 1,000 connections, kill the server, restart it. Watch what happens with instant reconnect (all at once, overwhelming) vs jittered backoff (spread out). The jitter difference is dramatic.


Check yourself

1. What is a cache stampede and why does it hit hardest at the worst time? A cache stampede is when a cached entry expires and, because it's being requested heavily, many concurrent requests all miss the cache at the same instant and hit the underlying resource (usually the database) simultaneously — for a key the database was sized never to serve, because the cache had been absorbing all of it. If a key served 10,000 requests/second from cache, its expiry means 10,000 concurrent database queries in one instant, overwhelming a database provisioned for the ~0 requests/ second it normally sees for that key. It hits hardest at the worst time for two compounding reasons: the stampede is proportional to the key's popularity (the more popular the key, the more concurrent requests are in flight at the moment it expires, so the biggest keys cause the biggest stampedes), and it's proportional to overall traffic (more total requests means more concurrent misses), so it's worst at peak load. The cruel irony is that the cache was protecting the database perfectly right up until the single instant of expiry — and it recurs, because when the database recovers and the cache repopulates, the TTL expires again later and the stampede repeats. So a system that looks perfectly healthy periodically self-inflicts an outage exactly when it's busiest.
2. What is request coalescing (single-flight) and why is it the key stampede fix? Request coalescing means that when many requests simultaneously miss the same cache key, only *one* of them actually recomputes the value (queries the database), while all the others *wait* for that single computation to finish and then use its result. So instead of 10,000 concurrent misses producing 10,000 database queries, the first request acquires a lock (or is designated the "leader"), computes the value, and populates the cache, and the other 9,999 either block until it's done or briefly serve a stale value — and the database sees exactly one query. It's the key stampede fix because it directly attacks the root cause: the stampede is *duplicate work* (thousands of requests all recomputing the same value that only needs to be computed once), and coalescing collapses that duplicate work to a single execution. It's implemented as Go's `singleflight`, Facebook's memcache "leases," and similar mechanisms everywhere. The costs are modest: the waiting requests block briefly (adding a little latency for them), and you need a coordination mechanism (a lock or in-flight-request registry). But it transforms the stampede from a database-crushing flood into a single query, which is why it's the first thing to reach for when a hot key can expire under load.
3. What's the difference between a cache stampede, a cache avalanche, and cache penetration? They're three distinct caching failure modes. A **cache stampede** is when *one hot key* expires and its many concurrent requesters all miss at once, slamming the database with duplicate queries for that single key — the problem is synchronized misses on a popular key, fixed by request coalescing and stale-while-revalidate. A **cache avalanche** is when *many different keys* expire at the same instant — typically because they were all created together (a deploy, a cache warm-up, a restart) with identical TTLs — so the database gets the full uncached load across many keys simultaneously, not just one key's worth; it's a broader synchronized-expiry problem, fixed by jittering TTLs so keys don't expire in lockstep. **Cache penetration** is entirely different: requests for keys that *don't exist anywhere* — not in the cache and not in the database — so every such request misses the cache and then misses the database (there's nothing to cache), giving the cache zero protective value; an attacker requesting random non-existent IDs exploits this to bypass the cache and hammer the database. It's fixed by caching the *negative* result (cache "not found" with a short TTL) or using a Bloom filter to reject definitely-missing keys before touching the database. Distinguishing them matters because they have different causes (hot key expiry vs synchronized bulk expiry vs nonexistent keys) and different fixes (coalescing vs jitter vs negative caching), and interviewers probe whether you know which is which.
4. Why do jittered TTLs prevent cache avalanches, and why is it such a valuable fix? A cache avalanche happens when many keys expire at the same instant, which occurs whenever they were populated together with the same TTL — for example, a deploy that warms the cache, a restart that repopulates it, or a batch job that loads many keys at once, all with `TTL = 300s`. Exactly 300 seconds later, they all expire simultaneously, and the database gets a synchronized flood of misses across all those keys. Jittering the TTL — using `TTL = 300s + random(0, 60s)` instead of a fixed 300s — spreads the expiry times across a window, so the keys expire gradually over that 60-second range rather than all at the same moment, and the database sees a smooth trickle of misses it can easily absorb rather than a cliff. It's such a valuable fix because it's essentially free (one line of code adding a random offset to the TTL), requires no coordination or additional infrastructure, and eliminates an entire class of outage — the synchronized-bulk-expiry avalanche — that is otherwise easy to trigger accidentally (any bulk cache population creates the risk). The general principle it embodies — add randomness to de-synchronize events that would otherwise align — recurs throughout distributed systems (jittered retry backoff, jittered reconnection, jittered scheduled jobs), and for caching specifically, jittered TTLs are one of the highest-value-per-effort mitigations available.
5. What's the unifying principle behind all thundering-herd fixes? Every thundering herd — cache stampede, cache avalanche, retry storm, reconnection storm, wake-up stampede — is fundamentally *synchronized demand hitting a resource all at once*: many actors doing the same expensive thing at the same instant, overwhelming a resource that could easily handle them if the demand were spread out. Correspondingly, every fix works by one of two mechanisms: **de-synchronizing the demand** so it spreads over time instead of arriving simultaneously — jittered TTLs (expiry spreads out), exponential backoff with jitter (retries and reconnections spread out), probabilistic early expiration (one key refreshes early rather than all at the expiry instant); or **collapsing the duplicate work** so the synchronized demand produces one unit of work instead of thousands — request coalescing / single-flight (one request recomputes, the rest wait), stale-while-revalidate (one background refresh serves everyone). Recognizing this unifying pattern is the practical payoff: whenever you see "everyone does X at the same moment and it overwhelms Y," you immediately know to reach for either de-synchronization (add jitter/randomness to spread the timing) or work-collapsing (deduplicate so the herd's demand becomes a single operation), rather than treating each thundering-herd variant as a separate mystery. It also connects the caching fixes (coalescing, jitter) to the resilience fixes (backoff, circuit breakers) as instances of the same underlying idea.

Further reading