staff-engineering-skills-thundering-herd
Prévenir les pics de demande synchronisés qui submergent les serveurs backends. Utiliser lors de la mise en œuvre de la mise en cache avec expiration TTL, de la planification de tâches cron, de la gestion de la reconnexion après…
npx skills add https://github.com/triggerdotdev/staff-engineering-skills --skill staff-engineering-skills-thundering-herdThundering Herd Trap
The cache expired. Ten thousand requests hit the database at once. Before writing any cache-miss handler, cron schedule, or reconnection logic, ask: what happens when every client does this at the same time?
The Four Triggers
| Trigger | What happens | Example |
|---|---|---|
| Cache expiry | Popular key expires, all concurrent requests miss and fetch simultaneously | Homepage feed cached for 5 min; on expiry, 10,000 requests hit the DB at once |
| Service recovery | Service comes back up, all queued retries hit it simultaneously | Downstream API recovers from outage, backlogged clients all retry at once |
| Synchronized scheduling | Every instance fires at the same second | Cron at 0 * * * * on 50 instances = 50 identical queries at :00 |
| Connection storm | All clients reconnect simultaneously after a failure | Database failover: 500 app servers all connect to the new primary at once |
Detection: When You're Creating a Thundering Herd
Stop and fix if you see:
-
"Check cache, miss, fetch, store" with no coalescing -- the textbook pattern. When 1,000 requests hit the miss simultaneously, 1,000 identical queries hit the database. Only one fetch is needed; the other 999 should wait for its result.
-
A fixed TTL on a popular cache key --
cache.set(key, data, { ttl: 300 }). All keys set at similar times expire at similar times. Especially dangerous after a deployment or cache restart that repopulates everything at once. -
Cron at round times --
0 * * * *,*/30 * * * *,0 0 * * *. Every instance fires at the same second. Every other system in the world also chose these times. Add jitter. -
cache.delete(key)orcache.clear()on a hot path -- invalidating a popular key causes an immediate stampede. Use write-through (set the new value) instead of delete, or use stale-while-revalidate. -
Reconnection logic without jitter --
on("error", () => connect()). Every client detects the failure at the same time and reconnects at the same time. The new server gets overwhelmed before it can serve anyone. -
Cache warming on startup with no stagger -- 50 pods restart during a deploy, each makes 50 warmup queries. That's 2,500 queries in seconds on top of normal load.
Patterns
Request coalescing (single-flight)
Only one request fetches on a miss. Everyone else waits for that result.
const inflight = new Map<string, Promise<any>>();
async function getWithCoalescing<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number): Promise<T> {
const cached = await cache.get(key);
if (cached) return JSON.parse(cached);
// If someone is already fetching this key, wait for their result
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
const promise = fetcher()
.then(async (data) => {
await cache.set(key, JSON.stringify(data), { EX: ttlSeconds + jitter(ttlSeconds) });
inflight.delete(key);
return data;
})
.catch((err) => {
inflight.delete(key);
throw err;
});
inflight.set(key, promise);
return promise;
}
function jitter(baseTtl: number): number {
return Math.floor(Math.random() * baseTtl * 0.2); // 0-20% jitter
}
This is in-process only -- works for single-instance services. For multi-instance, use a distributed lock (Redis SET NX PX) so only one instance across the fleet fetches.
Stale-while-revalidate
Serve stale data immediately, refresh in the background. The cache never has a true miss.
const refreshing = new Set<string>();
async function getWithSWR<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number): Promise<T> {
const entry = await cache.getWithTTL(key); // Returns { data, remainingTtl }
if (entry && entry.remainingTtl > 0) {
return entry.data; // Fresh
}
if (entry) {
// Stale but exists -- serve it, refresh in background
if (!refreshing.has(key)) {
refreshing.add(key);
fetcher()
.then((data) => cache.set(key, data, { EX: ttlSeconds + jitter(ttlSeconds) }))
.finally(() => refreshing.delete(key));
}
return entry.data; // Return stale immediately
}
// No data at all -- must fetch synchronously (with coalescing)
return fetchAndCache(key, fetcher, ttlSeconds);
}
The user gets a response in milliseconds (from stale cache) while the fresh data is fetched in the background. No stampede because there's never a moment where the cache is empty.
Tradeoff: Users may briefly see stale data. Fine for most content. Not acceptable for financial data or security-critical reads.
Jittered TTLs
Prevent mass expiry by spreading expirations across a time window.
function setWithJitter(key: string, data: any, baseTtlSeconds: number) {
// Add 0-20% random jitter
const ttl = baseTtlSeconds + Math.floor(Math.random() * baseTtlSeconds * 0.2);
return cache.set(key, JSON.stringify(data), { EX: ttl });
}
This doesn't prevent a stampede on a single hot key (all requests still miss at the same moment). But it prevents mass expiry -- 10,000 keys populated at the same time won't all expire in the same second.
Jittered cron scheduling
// BAD: all instances fire at exactly :00
cron.schedule("0 * * * *", generateReport);
// GOOD: each instance starts at a random offset within the first minute
const jitterMs = Math.floor(Math.random() * 60_000);
setTimeout(() => {
cron.schedule("0 * * * *", generateReport);
}, jitterMs);
Jittered reconnection
// BAD: reconnect immediately on failure
db.on("error", () => db.connect());
// GOOD: exponential backoff with jitter
db.on("error", () => {
const baseDelay = 1000;
const attempt = reconnectAttempts++;
const delay = baseDelay * 2 ** Math.min(attempt, 6) + Math.random() * 1000;
setTimeout(() => db.connect(), delay);
});
Without jitter, all clients reconnect at the same time after the same backoff duration. Jitter spreads them across a window so the server isn't overwhelmed.
Write-through instead of delete on invalidation
// BAD: delete causes a stampede on the next read
async function updateConfig(newConfig: Config) {
await db.save(newConfig);
await cache.delete("config"); // Every next request stampedes
}
// GOOD: write the new value directly -- no miss, no stampede
async function updateConfig(newConfig: Config) {
await db.save(newConfig);
await cache.set("config", JSON.stringify(newConfig), { EX: 300 });
// Cache goes from old value → new value with no gap
}
Anti-Patterns
// Naive cache-aside: 1,000 concurrent misses = 1,000 identical DB queries
const cached = await cache.get(key);
if (!cached) { data = await db.query(key); await cache.set(key, data, { ttl: 300 }); }
// Fixed TTL, no jitter: all keys populated at deploy expire at the same time
cache.set(key, data, { ttl: 300 }); // Every key gets exactly 300s
// Cron at round time: 50 instances all fire at :00
cron.schedule("0 * * * *", expensiveJob);
// Cache clear in hot path: nuclear stampede
await cache.clear(); // Everything stampedes at once
// Reconnect without backoff: connection storm
db.on("error", () => db.connect());
Related Traps
- Cache Invalidation -- cache stampede is the thundering herd problem applied to caching. Stale-while-revalidate and write-through invalidation prevent the gap where the cache is empty.
- Retry Storms -- retries are a specific form of thundering herd. When a service is slow and clients retry, the retries themselves create a herd on the struggling service. Exponential backoff with jitter is the same fix in both contexts.
- Hot Partitions -- a thundering herd on a partitioned system overwhelms whichever partition holds the hot key. The two traps compound.
- Backpressure -- a cache stampede is a sudden loss of backpressure. The cache was absorbing load; when it expires, the full load hits the backend with no buffering.