Engineering Atlas › System Design Fundamentals

Preventing a cache stampede

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Anatomy of a stampede

A key is read 2,000 times per second and takes 200 ms to compute. When it expires, every request in the next 200 ms misses before the first recompute finishes: 400 identical queries hit the database at once. If that pushes query latency to 2 seconds, the window grows to 2 seconds and 4,000 requests pile up, connection pools exhaust, and requests for other keys start failing too. The cache cannot recover because every recompute is slow.

Collapse duplicate work

Avoid synchronised expiry

Keys loaded at the same moment (after a deploy or a batch job) with the same TTL expire together. Adding random jitter, say TTL x uniform(0.9, 1.1), spreads them out. For individual hot keys, probabilistic early expiration lets each reader decide to refresh slightly before expiry, with probability rising as expiry approaches and scaled by how long the recompute takes (Vattani et al.). One reader usually refreshes early while everyone else still hits.

Serve stale

Keep values past their soft TTL. A request that finds a stale value returns it immediately and, if nobody else is refreshing, triggers a background refresh. HTTP formalises this as stale-while-revalidate and stale-if-error: max-age=600, stale-while-revalidate=30 allows 30 seconds of stale service while a background check runs (RFC 5861). Amazon calls the same idea a soft TTL, after which you refresh, and a hard TTL, until which you may keep serving if the refresh fails (AWS Builders' Library). This turns a stampede into one refresh per key and also protects users when the backend is down.

Mass stampedes

A cache node dying moves its keys elsewhere, all cold. A full flush during a deploy makes everything cold. Consistent hashing limits how many keys move, and warming or gradual traffic shifting limits how fast the cold keys are hit.

Down the stack

Layer 1The contract

Without protection, a cache promises nothing about backend load on a miss: N concurrent misses become N backend calls. A stampede-safe cache promises that at most one recompute per key is in flight (per process or per cluster), that callers during a refresh receive either a value no older than the soft TTL plus a stale window, or a bounded wait, and that a failed recompute releases the key within a known timeout.

Layer 2The mechanism

Go's singleflight.Group keeps a map from key to an in-flight call struct holding a sync.WaitGroup; the first caller runs the function, later callers block on the wait group and receive the same result and error. A distributed lock in Redis is SET lock:key token NX PX 3000. Release must delete only if the stored token still matches, so a slow holder cannot delete a successor's lock. That used to need a Lua script; since Redis 8.4 it is one command, DELEX lock:key IFEQ token, and SET ... IFEQ token PX 3000 extends a lock you still hold (Redis docs). The XFetch algorithm from Vattani and colleagues stores the recompute duration delta with the value; each reader refreshes early if now - delta * beta * ln(rand()) >= expiry, where rand() is uniform in (0, 1] and beta (default 1) tunes eagerness. Because ln(rand()) is negative, the left side jumps ahead by an exponentially distributed amount proportional to delta. Memcached leases at Facebook rate-limit lease issuance to one per key every 10 seconds, so other readers wait a few milliseconds or accept a slightly stale value. On keys prone to herds, that cut the peak database query rate from 17,000 to 1,300 per second (Facebook NSDI 2013).

Layer 3The machine

A recompute that takes 200 ms usually means a database query that reads many pages or an aggregation over many rows, consuming a connection, CPU and buffer pool space for its duration. Connection pools are small (often 10 to 50 per app server), so 400 concurrent recomputes queue in the pool and turn into timeouts. On the cache, a lock is one extra round trip of about 0.2 ms; waiting callers that poll every 20 ms add a few round trips, far cheaper than a database query. Inside a process, coalesced waiters cost a goroutine or a promise each, a few kilobytes of memory, versus a full query each.

Why it's built this way

  1. Caches exist because the backend cannot handle full read load, so any moment of 0% hit rate on hot keys exceeds backend capacity.
  2. Misses on one key arrive concurrently before the first recompute finishes, so duplicate work must be collapsed, not just cached afterwards.
  3. Locks add latency and failure modes, so serving slightly stale data is often the better trade.
  4. Expiries align after bulk loads, so randomness in TTLs and early refresh breaks the alignment.
  5. Recompute cost varies by key, so early refresh scales with measured recompute time instead of a fixed margin.

Diagram

sequenceDiagram
    participant A as "Request A"
    participant B as "Request B"
    participant C as "Cache"
    participant D as "Database"
    A->>C: get feed:home (soft TTL passed)
    C-->>A: stale value + you refresh (lock taken)
    B->>C: get feed:home
    C-->>B: stale value (refresh in flight)
    A->>D: recompute (200 ms)
    D-->>A: new value
    A->>C: set value, release lock
    B->>C: later get
    C-->>B: fresh value

Worked example

A news site's home feed key is read 3,000 times per second across 60 app servers. Computing it takes 250 ms and runs a query the database can handle at most 80 of concurrently.

No protection: at expiry, 3,000 x 0.25 = 750 requests miss in the first 250 ms. The database saturates at 80 concurrent queries, latency climbs to seconds, and the window keeps growing. The site goes down every 5 minutes, on the TTL.

In-process coalescing only: 60 servers each send one query, so 60 concurrent queries. Survivable, but close to the limit, and any other hot key expiring at the same time tips it over.

Serve stale plus a distributed lock: exactly one query per expiry, users get a value at most one recompute old (about 250 ms beyond soft TTL). The cost: one extra Redis round trip per refresh and storing values for an extra stale window (soft TTL 5 minutes, hard TTL 1 hour).

XFetch with delta = 0.25 s and beta = 1: the expected early-refresh lead is about delta x beta, so refreshes typically start in the last fraction of a second before expiry, triggered by one of the 3,000 readers per second. Combined with the lock, the key effectively never expires under load.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
TTL jitterTrivial, spreads bulk expiryDoes nothing for a single hot keyAlways, as a baseline
In-process coalescingNo coordination, no extra hopsOne recompute per server stillMany servers, moderate cost
Distributed lock or leaseOne recompute cluster-wideLock timeouts, extra round trip, waitersVery expensive recomputes
Serve stale while revalidatingNo user waits, survives backend outagesUsers see slightly old dataFeeds, catalogs, most read paths
Probabilistic early refreshNo locks, refresh before expiryProbabilistic, needs recompute timeHot keys with known compute cost

Pitfalls in production

Leadership lens

Check yourself

  1. Why does a stampede grow instead of resolving itself?

    Show answer

    The concurrent recomputes slow the backend, which lengthens the window during which new requests also miss, adding more load in a feedback loop.

  2. Why is in-process coalescing not always enough?

    Show answer

    It allows one recompute per server, so a large fleet still sends one query per server for every hot key that expires.

  3. How does probabilistic early expiration avoid a lock?

    Show answer

    Each reader independently decides to refresh with a probability that rises near expiry, so typically one reader refreshes early while the rest keep hitting the still-valid value.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →