Module P-7·13 min read

Three distinct failure modes requiring different solutions — XFetch probabilistic expiry for stampede, TTL jitter for avalanche, Bloom filter pre-gating for penetration. Detection patterns and production mitigations.

Written by Jatin Jain Saraf · Architect of SupraScan

P-7 — Cache Stampede, Avalanche, and Penetration

Who this module is for: Your cache is configured and working. Then, under load or at a specific moment, your database CPU spikes to 100%, response times collapse, and the system partially recovers when the cache warms up again. This is a cache failure — and there are three distinct failure modes, each requiring a different fix. Treating all three the same is why most mitigation attempts fail.


The Three Failure Modes

FailureTriggerSymptomSolution
Cache stampedeOne popular key expiresSudden DB spike on one queryProbabilistic expiry, mutex lock
Cache avalancheMany keys expire simultaneouslySustained DB overloadTTL jitter, pre-warming
Cache penetrationRequests for non-existent keysSustained DB queries with empty resultsNull caching, Bloom filter

Each has a different root cause, different detection signature, and different fix.


Cache Stampede (Thundering Herd)

What It Is

A single popular cache key expires. In the milliseconds before the first request can recompute and repopulate it, dozens or hundreds of concurrent requests see a miss and all race to recompute the same expensive query. The database receives N identical queries simultaneously.

Detection

# In your metrics:
database_query_count → spike to N×normal for ~duration_of_recompute
cache_miss_rate → spike on a specific key

The spike is narrow and short-lived — it resolves when the first request finishes recomputing and populates the cache. The next expiry cycle causes another spike.

Fix 1: Probabilistic Early Expiry (XFetch Algorithm)

Instead of waiting for the key to expire, some requests recompute before expiry with a probability proportional to how close the key is to expiry. This "warms" the key proactively, preventing the expiry from ever causing a stampede.

async function getWithXFetch<T>(
  key: string,
  ttl: number,  // desired TTL in seconds
  compute: () => Promise<T>,
  beta: number = 1.0  // higher = more aggressive early recompute
): Promise<T> {
  const data = await redis.get(key);
 
  if (data) {
    const { value, delta, expiry } = JSON.parse(data);
    const remainingTTL = expiry - Date.now() / 1000;
 
    // Recompute with probability proportional to remaining time and compute cost
    // XFetch: recompute if -delta * beta * log(random()) > remainingTTL
    const shouldRecompute = -delta * beta * Math.log(Math.random()) > remainingTTL;
 
    if (!shouldRecompute) {
      return value as T;
    }
    // Fall through to recompute
  }
 
  const startTime = Date.now();
  const value = await compute();
  const delta = (Date.now() - startTime) / 1000;  // recompute time in seconds
 
  const payload = JSON.stringify({
    value,
    delta,
    expiry: Date.now() / 1000 + ttl,
  });
 
  await redis.set(key, payload, 'EX', ttl);
  return value;
}

The delta (recompute time) is stored alongside the value. Expensive queries get earlier preemptive recompute because they have a larger delta. The beta parameter controls aggressiveness — beta = 1 is the standard algorithm.

Fix 2: Mutex Lock (Single Flight)

Only one request recomputes at a time. Others wait for the lock to be released, then serve from the (now-populated) cache.

async function getWithLock<T>(
  key: string,
  ttl: number,
  compute: () => Promise<T>
): Promise<T> {
  const lockKey = `lock:${key}`;
 
  // Try cache first
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
 
  // Try to acquire lock (NX = only if not exists, EX = expire in 10s)
  const locked = await redis.set(lockKey, '1', 'NX', 'EX', 10);
 
  if (locked === 'OK') {
    try {
      // Double-check cache (another process may have populated it)
      const recheck = await redis.get(key);
      if (recheck) return JSON.parse(recheck);
 
      const value = await compute();
      await redis.set(key, JSON.stringify(value), 'EX', ttl);
      return value;
    } finally {
      await redis.del(lockKey);
    }
  } else {
    // Wait for the lock holder to finish, then serve from cache
    await new Promise(resolve => setTimeout(resolve, 50 + Math.random() * 50));
    return getWithLock(key, ttl, compute);  // recursive retry
  }
}

Trade-off: Lock holders that crash leave the lock in place until EX expires. Set the lock TTL to exceed the maximum expected compute time.


Cache Avalanche

What It Is

Many cache keys expire at roughly the same time. If all your cached data was loaded at startup (cold start after deployment) with the same TTL, all keys expire together. The database receives a flood of queries across many different data types — not a spike on one query, but a sustained overload across all queries.

Detection

cache_miss_rate → sustained elevation across many different keys/endpoints
database_query_count → broad sustained spike (not narrow like stampede)
application_response_time → slow across all endpoints, not one

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.