Distributed Caching

One Key Expires and Five Hundred Instances Miss at the Same Millisecond

In one process, a stampede is a few threads racing to recompute the same value and a mutex solves it. Across five hundred instances there is no mutex to take, so the database receives every one of those misses at once — for a single key.

▶ Run the lab

The question this answers

The question

A hot key just expired and every instance missed simultaneously. What stops all of them hitting the database?

The guarantee — the property claimed, and its scope

With per-instance coalescing: at most one origin request per instance per key per recompute. With a distributed lease: at most one origin request per key across the fleet, *provided* the lease store is available and the lease outlives the recompute. Neither guarantees a fresh value — under a lease, other readers get stale data or a wait, and that choice must be explicit.

Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.

What a node knows — observation versus inference

An instance knows it missed and that it is about to query the origin. It does not know that four hundred and ninety-nine peers missed the same key in the same millisecond — the misses are simultaneous precisely because they share a cause, and no instance can observe the shared cause. Coordinating requires a place all of them can see, which is why the mitigations are either local coalescing or a lock in the shared cache.

A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.

What guarantee?What does a node know?How does it work?What can fail?How does it fail?Where is coordination?What holds under failure?How does it recover?How would you know?What is the simpler thing?
stampedethundering herdsingleflightleasesTTL

Why the fleet multiplies what a mutex would have solved

Concurrency owns the in-process version of this: a thundering herd, solved with singleflight or a mutex around the recompute. Architecture and Backend cover the single-service cache-aside pattern. The distributed case is a different quantity of the same shape, and the quantity is what makes it an outage.

Five hundred service instances, each handling forty concurrent requests for a popular product page. The key expires. Every instance misses, and within each instance forty requests miss simultaneously. That is 20,000 identical database queries arriving inside a few milliseconds for one key whose correct answer is a single row.

Per-instance singleflight collapses the forty into one, taking it to 500. That is the cheapest, most reliable mitigation and it should always be present — but 500 simultaneous queries for one row is still enough to saturate a connection pool, and if the recompute is expensive (an aggregate, a join, a model call) 500 is plainly too many.

Getting from 500 to 1 requires the instances to agree, and the only thing they all already talk to is the cache itself. So the shared cache becomes a coordination service, which means you have acquired a distributed lock and all of its problems — a lock holder that crashes, a lease that expires mid-recompute, waiters that block. That is Distributed Locks: What They Are Actually For and Leases: Authority With an Expiry Date arriving through the back door of a performance optimisation, and it is worth naming as such before adopting it.

fleet: 500 instances x 40 concurrent requests   TTL expiry at t=0

no mitigation                        20,000 origin queries in ~5ms
per-instance singleflight               500 origin queries in ~5ms
  + TTL jitter (+/-10%)                 500 spread over ~60s (~8/s)
  + stale-while-revalidate                1 origin query; readers served stale
  + distributed lease                     1 origin query; others wait or serve stale

probabilistic early expiry             ~1 query, spread before expiry, no lock
One expired key, by mitigation

The ladder of mitigations, cheapest first

1. TTL jitter. Write entries with ttl × (1 ± 0.1). Keys populated together no longer expire together, which dissolves the correlated-expiry class of stampede entirely. It costs one line, needs no coordination, and it is the fix people skip because it feels too small to matter. It is the highest value-per-effort item in this module. Note it does not help a *single* hot key, which expires once no matter how you jitter it — it helps the far more common case of a whole key space expiring in unison after a bulk load or a deploy.

2. Per-instance singleflight. One in-flight recompute per key per process; every other caller awaits the same promise. Local, free, no failure modes worth the name, and it divides the load by your per-instance concurrency. Always do this.

3. Stale-while-revalidate. Serve the expired value and refresh in the background. Readers never block, the origin sees one refresh, and the price is explicitly-served stale data. This is the best answer for most read paths, and its own failure mode deserves attention: if the background refresh keeps failing silently, you serve stale forever. It needs a hard staleness bound past which you refuse to serve, and a metric on refresh failures.

4. Probabilistic early expiry. Each reader independently decides to refresh slightly before the TTL, with a probability that rises as expiry approaches: recompute when now − delta × beta × ln(rand()) ≥ expiry, where delta is the measured recompute duration. Expensive-to-recompute keys are refreshed earlier, cheap ones later, and refreshes spread out across readers without any lock. It is the most elegant option and it is under-used because it looks like folklore — the paper is Vattani, Chierichetti & Lowenstein, "Optimal Probabilistic Cache Stampede Prevention" (VLDB 2015).

5. Distributed lease. One instance takes a short-lived lock in the cache (SET lock:k <token> NX PX 5000) and recomputes; others either wait briefly or serve stale. Gets you to exactly one origin query and introduces a lock holder that can die. The lease TTL becomes a latency cliff: if the holder crashes, everyone waits for the lease to expire. Use it only when the recompute is genuinely too expensive to do 500 times, and always pair it with stale-serving so waiters have something to return.

1// XFetch (Vattani et al., VLDB 2015). Each reader independently decides to
2// refresh early; the probability rises as expiry approaches and scales with how
3// expensive the recompute is. No coordination, no lock, no cliff.
4function shouldRefreshEarly(entry: Entry, beta = 1.0): boolean {
5 const nowMs = Date.now()
6 const gap = entry.recomputeDurationMs * beta * -Math.log(Math.random())
7 return nowMs + gap >= entry.expiresAtMs
8}
9
10async function get(key: string): Promise<Value> {
11 const entry = await cache.get(key)
12
13 if (entry && !shouldRefreshEarly(entry)) return entry.value
14
15 if (entry) {
16 // Serve stale immediately; refresh behind the request. Singleflight keeps
17 // this to one recompute per instance even under heavy concurrency.
18 void singleflight(key, () => recompute(key)).catch(() => refreshFailures.inc())
19 return entry.value
20 }
21
22 // Cold miss: nothing to serve, so we must wait — but only one of us goes.
23 return singleflight(key, () => recompute(key))
24}
25
26async function recompute(key: string): Promise<Value> {
27 const started = Date.now()
28 const value = await origin.load(key)
29 await cache.set(key, {
30 value,
31 recomputeDurationMs: Date.now() - started, // feeds the early-refresh decision
32 expiresAtMs: Date.now() + jitteredTtlMs(),
33 })
34 return value
35}
Probabilistic early expiry plus stale-while-revalidate — no lock required

The stampede that is not caused by expiry

Expiry is the textbook trigger and it is not the most common one in production. Three others produce identical symptoms and are not fixed by anything above.

A cold start. A deploy replaces every instance, so every local cache is empty at once. The origin sees full traffic until the fleet warms. The fix is not a cache technique at all: stagger the rollout, gate readiness on a warm cache, and pre-warm the top keys before accepting traffic.

An eviction wave. The cache hits its memory limit and evicts a large fraction of keys under LRU pressure, or a cache node restarts and its share of the keyspace vanishes. With consistent hashing, losing one node of ten sends 10% of all keys to the origin simultaneously.

An invalidation storm. A bulk update at the source invalidates a hundred thousand keys at once — a price import, a permission change, a schema migration. Every one of those keys is now a miss, and the "invalidation" that was supposed to protect correctness has manufactured a stampede. This is where You Cannot Enumerate the Caches, So TTL Is the Bound and Invalidation Is the Optimisation and this lesson meet, and it is why bulk invalidations must be rate-limited rather than issued as fast as the source can emit them.

The unifying observation: a stampede is any event that correlates misses across the fleet. Expiry is one such event; deploys, evictions and bulk invalidations are others. The mitigations that survive all four are the ones that limit origin concurrency directly — singleflight, admission control at the origin, and serving stale — rather than the ones that only spread expiry times.

TriggerTTL jitterSingleflightStale-while-revalidateWhat really fixes it
Correlated TTL expiryprotocolYes — the fixYesYesJitter
Single hot key expiringassumptionNoPartly (500 not 20,000)YesEarly expiry or a lease
Deploy / cold starttypicalNoYesNo — nothing to serveStaggered rollout + pre-warm
Eviction wave / node losstypicalNoYesNoHeadroom + origin admission control
Bulk invalidationtypicalNoYesYes, if stale is acceptableRate-limit the invalidation itself
Stampede triggers and which mitigations actually apply

Key points

  • The distributed stampede is the in-process one multiplied by fleet size: 500 instances × 40 concurrent requests is 20,000 queries for one key.
  • Per-instance singleflight is free and always correct, and it divides the load by per-instance concurrency — but 500 simultaneous queries can still be too many.
  • Getting to one origin query per key requires a lock in the shared cache, which imports every problem of distributed locks and leases.
  • TTL jitter is the highest value-per-line mitigation, and it addresses correlated expiry rather than a single hot key.
  • Most production stampedes are triggered by deploys, evictions and bulk invalidations, none of which TTL jitter helps with.

The chain, answered

Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.

How it works
  • A cached entry expires, or a fleet-wide event invalidates or empties a large set of entries at once.
  • Every instance misses simultaneously because they share the cause, and no instance can observe that the others missed.
  • Without mitigation, each concurrent request in each instance issues its own origin query.
  • Singleflight collapses concurrent requests within one process to a single in-flight recompute.
  • Cross-instance coalescing requires a shared lease or lock, or is avoided entirely by serving stale while one refresh proceeds.
What can fail at the boundary
  • The lease holder crashes mid-recompute, and every waiter blocks until the lease TTL expires.
  • The lease expires while the recompute is still running, so a second instance starts recomputing and the guarantee of one is lost.
  • The background refresh in stale-while-revalidate fails repeatedly and silently, so stale data is served indefinitely.
  • The recompute is slower than the TTL, so the key is never successfully populated and every request is a miss forever.
  • The lock store is the cache itself, so a cache outage removes both the cache and the mechanism protecting the origin from the resulting stampede.
How it fails — what an operator sees
  • Periodic origin spikes aligned with a TTL boundary: database CPU shows a sawtooth with a period exactly equal to the TTL, and connection pool saturation at each peak.
  • Latency cliff of exactly the lease duration: p99 shows a flat shelf at 5,000ms — the lease TTL — because a holder died and every waiter blocked until it expired.
  • Indefinite staleness under stale-while-revalidate: served entry age keeps climbing past the TTL while error rates stay at zero. The refresh has been failing silently and nothing counts it.
  • Stampede on deploy: origin load spikes on every rollout, proportional to the number of instances replaced, with no change in user traffic. Cold local caches, and no cache-level mitigation touches it.
Where coordination is required
  • Per-instance singleflight is local and coordination-free, which is why it should always be the first layer.
  • Cross-instance coalescing is genuine coordination: a lease in the shared cache, with all the availability and stale-holder concerns of Distributed Locks: What They Are Actually For.
  • Probabilistic early expiry achieves approximate coalescing with no coordination at all, by having each reader randomise independently — the same trick as jitter, applied to refresh timing.
What still holds under failure
  • Under a lease-holder crash, correctness is preserved and latency is not: waiters block for up to the lease duration unless they can serve stale.
  • Under stale-while-revalidate, availability is preserved and freshness is not, and the staleness is unbounded unless you enforce a hard limit.
  • If the shared cache is unavailable, all mitigations that depend on it vanish simultaneously with the cache — the origin needs its own admission control as the real backstop.
How it recovers
  • Detect: origin query rate per cache key, or at least per key prefix. A stampede is invisible in aggregate query rate and obvious per key.
  • Contain: singleflight everywhere, jitter every TTL, and put admission control at the origin so a stampede degrades rather than saturates.
  • Recover: after an eviction or restart, re-warm the top keys deliberately rather than letting user traffic do it, and hold readiness until warm.
  • Reconcile: values served stale during a stampede may have been materially wrong; if that matters, record the served age so downstream can tell.
  • Verify: expire a hot key deliberately under load and count origin queries. The number should be close to one, or to your instance count, and you should know which you expect.
How you would know
  • Origin queries per key over short windows — the only view in which a stampede is visible at all.
  • Age of served entries, including entries served past their TTL, which reveals stale-while-revalidate quietly failing.
  • Background refresh failure count, without which indefinite staleness produces no signal whatsoever.
  • Lease wait time and lease acquisition failures, which expose the latency cliff before a user does.
When it helps
  • Any cache with keys hot enough that a single expiry is visible at the origin — which is most caches with a real popularity skew.
  • Expensive recomputes (aggregations, joins, model inference) where even a handful of duplicate computations is costly.
  • Fleets large enough that per-instance singleflight alone leaves too many concurrent origin requests.
When it hurts
  • Uniform-popularity key spaces with cheap recomputes, where a stampede for one key is indistinguishable from normal load and the machinery is pure complexity.
  • When a lease is added without stale-serving, converting a load problem into a latency cliff for every waiter.
  • When mitigations are layered without measurement, so nobody knows which one is doing the work or whether any of them is.
Simpler alternatives
  • Never expire: refresh proactively on a schedule and treat the cache as a materialised view with a writer. Removes the stampede entirely and costs continuous refresh work.
  • Serve stale unconditionally with a background refresher, accepting a known staleness bound and having no miss path at all.
  • Size the origin to absorb the worst-case stampede. Occasionally the cheapest answer for a small key space, and it should be checked before building coordination.
  • Precompute into a store that is itself the read path, which is Materialized Views: A Read Model That Lags rather than caching.

Cache stampede: there is no mutex to take

Cache stampede: there is no mutex to take
In one process a stampede is a few threads racing to recompute the same value and a mutex solves it. Across a fleet there is no shared mutex, so the origin receives every one of those misses at once — for a single key.
scenario
origin queries at the expiry
10K/s
against origin capacity
5.00×
with nothing enabled
10K/s
origin regime
shedding
10K/s0
origin queriesorigin capacity↑ the key expires40 seconds
RungOrigin queries at the expiryWhat it costs
nothing10K/severy concurrent request on every instance missesstill over
+ TTL jitter10K/sno effect — one key expires oncestill over
+ singleflight1,250/sone in-flight recompute per key per processfits
+ stale-while-revalidate3/sserve the expired value, refresh in the backgroundfits
+ distributed lease3/sexactly one origin query, and a lock holder that can diefits
Climb the ladder cheapest first. TTL jitter costs one line and removes correlated expiry entirely. Per-instance singleflight is free and divides the load by your per-process concurrency — always do this. Stale-while-revalidate is the best answer for most read paths, and its own failure mode deserves a metric: if the background refresh keeps failing silently you serve stale forever, so it needs a hard staleness bound past which you refuse to serve.
The distributed lease gets you to exactly one origin query and introduces a lock holder that can die. Its TTL then becomes a latency cliff: everyone waits for the lease to expire. Use it only when the recompute is genuinely too expensive to do many times, and always pair it with stale-serving so the waiters have something to return. And note the stampede that is not caused by expiry at all — an eviction under memory pressure, a cache restart, or a deploy that empties every local cache produces the same wall with no TTL involved.
assumptionMiss counts assume every instance is serving the key at the stated rate and that all of them miss inside one recompute window — the worst case, and the one that happens. The origin's response to that load comes from the queue model and inherits its independence assumptions.

What people believe, and what is true

Claim

We use singleflight, so stampedes are handled.

Reality

Singleflight is per process. With 500 instances you still get 500 simultaneous origin requests for one key, which is enough to saturate a connection pool.

Claim

A distributed lock is the proper fix.

Reality

It is the strongest fix and it imports lock holders that crash, leases that expire mid-work, and a latency cliff for waiters. Reach for stale-serving and early expiry first.

Claim

TTL jitter solves cache stampedes.

Reality

It solves *correlated expiry*. A single hot key still expires once, and deploys, evictions and bulk invalidations are untouched by it.

Claim

Stale-while-revalidate has no failure mode — we always serve something.

Reality

If the background refresh keeps failing, you serve stale forever with no error. It needs a hard staleness bound and a refresh-failure metric.

Go deeper

Only the levels this lesson can honestly fill — a missing level is a claim nobody had.

Overview

When a popular key expires, every instance misses at the same moment and the database gets all of those misses at once. Coalesce them, spread the expiry times, or serve the old value while one instance refreshes.

Practical

Always jitter TTLs and always singleflight per instance. Add stale-while-revalidate with a hard staleness bound and a refresh-failure counter. Reach for a distributed lease only for genuinely expensive recomputes, and pair it with stale-serving so waiters are not blocked on a lease TTL.

Advanced

Prefer probabilistic early expiry to a lock: each reader refreshes early with a probability that grows as expiry nears and scales with the measured recompute cost, so refreshes spread across readers with no coordination and no cliff. Then reason about the trigger rather than the mechanism — expiry, deploy, eviction and bulk invalidation all produce correlated misses, and only the mitigations that bound origin concurrency directly survive all four.

Apply it

Interview questions
  • 💬 A hot key expires across a 500-instance fleet. How many origin queries, and how do you get that to one?
  • 💬 What does a distributed lease around the recompute cost you when the holder crashes?
  • 💬 Name three stampede triggers that TTL jitter does not help with.