Fixing the Thundering Herd: How Cache Stampede Almost Took Down My Node.js API
In this developer note, we investigate the cache stampede problem (aka thundering herd) that took our Node.js API from 120ms to 8.4s p99 latency under a traffic spike, and walk through the deterministic single-flight lock pattern that reduced database load by 94%.
The Problem & Root Cause Analysis
At 14:02 UTC, our flagship API endpoint GET /api/v1/dashboard began returning 504s to 12% of clients. CPU on the Node process was only 34%, Postgres was at 61% — nothing looked overloaded. Yet p99 latency climbed from 120ms to 8,400ms within 90 seconds.
The Symptom
Every 60 seconds, our Redis cache key dashboard:tenant:* expired simultaneously. Hundreds of incoming requests all saw a cache miss at the exact same millisecond, and every single one fired an expensive 1.8s Postgres aggregation query in parallel — overwhelming the DB connection pool.
The Root Cause
Classic cache stampede (thundering herd). Our naive cache layer had no single-flight guard. When N requests arrive on a cache miss, all N hit the database — but only 1 query is actually needed. We needed a lock so only 1 request refills the cache while the others wait.
Before vs. After: The Code Fix
Here's the exact side-by-side diff that eliminated the stampede. The "before" version blindly refills the cache on every miss. The "after" version uses a Redis SET NX lock so only one request refills while the others wait briefly.
// ❌ Every miss = 1 DB query. N misses = N queries.
export async function cached<T>(
key: string,
ttl: number,
fetcher: () => Promise<T>
): Promise<T> {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
// 💥 All concurrent callers land here at once
const fresh = await fetcher();
await redis.set(key, JSON.stringify(fresh), 'EX', ttl);
return fresh;
}
// ✅ Only 1 caller fetches. Others wait for the result.
export async function cached<T>(
key: string,
ttl: number,
fetcher: () => Promise<T>
): Promise<T> {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const lockKey = `${key}:lock`;
const acquired = await redis.set(
lockKey, '1', 'EX', 10, 'NX'
);
if (!acquired) {
// Wait 50ms and retry — cache will be warm soon
await new Promise(r => setTimeout(r, 50));
return cached(key, ttl, fetcher);
}
try {
const fresh = await fetcher();
// Jittered TTL to prevent future simultaneous expiry
const jitteredTtl = ttl + Math.floor(Math.random() * 30);
await redis.set(key, JSON.stringify(fresh), 'EX', jitteredTtl);
return fresh;
} finally {
await redis.del(lockKey);
}
}
Complete Implementation & Config
Full production-ready implementation including the Redis client, the cache wrapper, and the Docker Compose setup for local testing:
import { redis } from './redis.config';
interface CachedOptions {
ttl: number;
lockTtl?: number;
retryDelayMs?: number;
jitterMax?: number;
}
export async function cached<T>(
key: string,
fetcher: () => Promise<T>,
options: CachedOptions
): Promise<T> {
const {
ttl,
lockTtl = 10,
retryDelayMs = 50,
jitterMax = 30,
} = options;
// 1. Try hot path
const hit = await redis.get(key);
if (hit) return JSON.parse(hit) as T;
// 2. Try to acquire single-flight lock
const lockKey = `${key}:lock`;
const acquired = await redis.set(lockKey, '1', 'EX', lockTtl, 'NX');
if (!acquired) {
// 3. Someone else is fetching. Wait and retry.
await new Promise(r => setTimeout(r, retryDelayMs));
return cached(key, fetcher, options);
}
try {
// 4. We own the lock. Fetch fresh data.
const fresh = await fetcher();
const jitteredTtl = ttl + Math.floor(Math.random() * jitterMax);
await redis.set(key, JSON.stringify(fresh), 'EX', jitteredTtl);
return fresh;
} finally {
await redis.del(lockKey);
}
}
import Redis from 'ioredis';
export const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD,
db: 0,
maxRetriesPerRequest: 3,
enableReadyCheck: true,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
reconnectOnError(err) {
const targetErrors = ['READONLY', 'ECONNRESET'];
return targetErrors.some(e => err.message.includes(e));
},
});
redis.on('connect', () => console.log('[Redis] Connected'));
redis.on('error', (err) => console.error('[Redis] Error:', err.message));
redis.on('reconnecting', () => console.warn('[Redis] Reconnecting...'));
version: '3.8'
services:
redis:
image: redis:7.2-alpine
container_name: dev-redis
restart: always
ports:
- "6379:6379"
command: >
redis-server
--maxmemory 256mb
--maxmemory-policy allkeys-lru
--save ""
--appendonly no
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
postgres:
image: postgres:16-alpine
container_name: dev-postgres
restart: always
environment:
POSTGRES_USER: dev_admin
POSTGRES_PASSWORD: dev_secure_password
POSTGRES_DB: app_database
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Step-by-Step Patch & Verification Workflow
Follow these four steps to reproduce, patch, and verify the fix in staging before rolling to production:
Step 1: Reproduce with a Concurrent Load Test
Use autocannon with 200 concurrent connections against a cold cache. Watch the DB connection pool saturate while CPU stays idle. This is the stampede signature.
Step 2: Wrap the Cache with a Single-Flight Lock
Replace raw get → miss → set logic with the cached() helper above. The Redis SET NX command ensures only one worker runs the expensive query.
Step 3: Add TTL Jitter
Never set a fixed TTL. Add + Math.random() * 30 seconds of jitter so multiple cache keys don't expire at the same instant, avoiding the next stampede window.
Step 4: Verify Under Load
Re-run the load test. Confirm only one query fires per cache miss (check your DB logs) and p99 latency returns under 200ms.
Developer News & Release Highlights
Related ecosystem updates worth knowing if you're building cache-heavy services in 2026:
Redis 7.4 — Native Client-Side Caching
Redis now ships a client-side cache that stores hot keys directly in your Node process memory, reducing round trips for read-heavy endpoints by up to 70%.
ioredis v6 Drops Legacy Callbacks
Callback-style redis.get(key, (err, val) => ...) is fully removed. All APIs are Promise-only. Update any legacy code paths before upgrading.
Node 22 — AsyncLocalStorage 3x Faster
Context propagation across async boundaries is now 3x cheaper, making it viable for distributed tracing inside high-frequency cache wrappers.
Terminal Cheatsheet & Diagnostic Commands
Keep these one-liners handy when debugging Redis + Postgres performance under load:
Architecture & Profiling Visuals
Developer Pro-Tip
Never set a fixed TTL on your cache keys. Always add jitter (e.g., ttl + Math.random() * 30 seconds). This spreads cache expiry across a window instead of a single millisecond, preventing the next stampede before it happens.
Source Code, Docs & Benchmarks
GitHub Example Repo
Full reproduction sandbox with Node, Redis, Postgres, and Autocannon load scripts ready to run.
ioredis Docs
Reference for SET NX EX, lock patterns, and Redis client configuration for production Node apps.
Developer Discussions
Hit a stampede in a different stack? Share your scenario and let's compare fixes.
One Lock Beat a Whole Team of Retries
Adding a single Redis SET NX lock cut our database load by 94% and pulled p99 latency from 8,400ms down to 180ms — without changing a single line of business logic. Deterministic concurrency control beats "just add more servers" every time.
Comments
Post a Comment