ML
System Design

The Thundering Herd on Cache Invalidation: Stale-While-Revalidate vs Probabilistic Early Expiration (XFetch)

When a hot cache key expires under heavy traffic, thousands of concurrent requests miss at the exact same millisecond and attempt to recompute the same expensive value, crushing the database. We compared distributed locking, Stale-While-Revalidate, and probabilistic early expiration (XFetch) to solve the cache stampede.

August 04, 20269 min readSystem DesignRedisCaching

When a key holding a heavily requested item—like a trending product page or top news feed—expires in Redis or Memcached, every incoming request at that exact millisecond experiences a cache miss. In a system handling thousands of queries per second, hundreds or thousands of worker threads simultaneously attempt to compute or fetch the missing value from the primary database. This is the classic cache stampede or thundering herd problem.

The Anatomy of a Thundering Herd Crash

In a standard naive caching implementation, the flow looks like this:

val = redis.get(key)
if val is None:
    val = db.query_expensive_data() # Takes 300ms
    redis.setex(key, 60, val)
return val

If key homepage_feed expires at 14:00:00.000 and the server receives 500 requests per second, all 500 threads check Redis, receive None, and issue 500 identical complex SQL queries to the database. The database CPU spikes to 100%, query latency inflates from 300ms to 15 seconds, and worker threads exhaust the connection pool, creating a total outage.

Strategy 1: Distributed Mutex Locking (Single-Rebuilder)

The first common attempt to fix this is introducing a distributed lock (e.g. SET key:lock uuid NX PX 5000). Only the thread that successfully acquires the lock executes the expensive database query, while all other threads sleep briefly and retry fetching from Redis.

val = redis.get(key)
if val is None:
    if redis.set("lock:" + key, my_id, nx=True, px=5000):
        try:
            val = db.query_expensive_data()
            redis.setex(key, 60, val)
        finally:
            release_lock("lock:" + key, my_id)
    else:
        time.sleep(0.05)
        return get_with_lock(key) # Retry read
return val

Pros: Guarantees exactly one database query per cache expiration.
Cons: Waiting threads experience added latency spikes equal to the database query execution time plus retry polling delays. If the locking worker crashes before releasing the lock, waiting threads hang until the lock TTL expires.

Strategy 2: Background Stale-While-Revalidate (SWR)

Instead of letting a key expire hard, the data payload includes a stale_at timestamp alongside a longer TTL. When a request reads data past stale_at but before full TTL expiration, it returns the stale value instantly to the user while asynchronously enqueueing a background worker to re-generate the fresh cache entry.

payload = redis.get(key) # { data: {...}, stale_at: 1720000000 }
if payload is None:
    return fetch_blocking_and_cache(key)

if now() > payload.stale_at:
    if redis.set("revalidate_lock:" + key, "1", nx=True, ex=10):
        async_queue.enqueue(revalidate_task, key)

return payload.data # Instant response even when stale

Pros: Zero user-facing latency spikes; response time remains flat.
Cons: Requires background task queues (Celery/BullMQ) and consumes memory by holding stale data longer.

Strategy 3: Probabilistic Early Expiration (XFetch Algorithm)

Formulated by Vattani et al. in 2015, the XFetch algorithm uses probability to make worker threads trigger cache re-computation before the key actually expires, scaling with current request load.

The probability of recomputing early increases as the current time approaches expiration, weighted by the computation cost ($delta$) and a tuning constant ($eta > 0$):

$$ ext{Recompute if } -Delta cdot eta cdot ln( ext{rand}()) > ext{ttl_remaining}$$
import math, random, time

def xfetch_read(redis, key, beta=1.0):
    val, delta, ttl_remaining = redis.get_with_ttl_and_compute_cost(key)
    
    if val is None or (-delta * beta * math.log(random.random()) > ttl_remaining):
        start = time.time()
        new_val = db.query_expensive_data()
        compute_delta = time.time() - start
        
        redis.set_with_metadata(key, new_val, ttl=60, compute_delta=compute_delta)
        return new_val
        
    return val

Under heavy traffic, the random log function ensures that exactly one worker thread probabilistically decides to recompute the cache slightly before expiration while the key is still valid. The new entry overwrites the old key seamlessly, so the cache never drops to zero or misses hard.

Comparison Summary

  • Mutex Locking: Best for write-heavy or low-traffic systems where stale data is strictly unacceptable.
  • Stale-While-Revalidate: Best for high-traffic web applications (e.g., e-commerce, news feeds) where serving slightly stale content for a few seconds is completely fine.
  • XFetch: Best for high-concurrency microservices with expensive computations where you want zero background queue overhead and optimal cache hit ratios.
SharePostLinkedIn

Reader Discussion

1 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Rachel Gold· Staff SREAgrees

    the on-call framing throughout this piece is what makes it land. too many infra articles assume you never get paged. those are written by people who never got paged.

    Aug 07, 2026·3 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email