What is a cache stampede?
A cache stampede—also called a thundering herd—happens when many requests discover the same missing or expired cache entry and independently recompute it. A popular product page, tenant configuration, pricing catalogue, or expensive aggregate can turn one expiry into hundreds of database queries or upstream API calls. The cache is healthy, yet the origin becomes the bottleneck.
The risk is not limited to high traffic. Synchronized TTLs can expire a large group of keys together after a deployment, import, or bulk invalidation. A Redis restart, aggressive eviction policy, cold regional rollout, or failed refresh worker can create the same shape. The correct objective is therefore broader than hit rate: bound origin concurrency for each logical resource, preserve an acceptable stale response when possible, and recover without permanent locks or poisoned data.
Choose a stampede-control strategy by failure semantics
| Technique | Best fit | Trade-off |
|---|---|---|
| In-process single-flight | Duplicate requests inside one Node.js instance | Cannot coordinate across replicas or regions |
| Randomized TTL (jitter) | Preventing many keys from expiring together | Does not protect one extremely hot key |
| Stale-while-revalidate | Read-heavy data with a safe stale window | Clients may briefly receive older data |
| Per-key Redis lock | Coordinating a refresh across application replicas | Needs lease expiry, ownership-safe release, and a waiter policy |
| Proactive refresh | Predictably hot keys with measurable refresh cost | Can refresh data nobody will request |
These techniques compose. A practical default is in-process single-flight, TTL jitter, and stale-while-revalidate. Add a distributed per-key lock only when duplicate work across replicas is costly enough to justify the operational complexity. Keep the origin protected with its own concurrency limit, query timeout, and circuit breaker; Redis is a coordination layer, not the final safety boundary. Our guide to Node.js circuit breakers explains that outer dependency protection.
Cache-aside data flow
- Read a versioned cache key.
- Return a fresh value immediately.
- If the value is stale but still usable, return it and let one caller refresh in the background.
- If no usable value exists, let one caller load the origin while peers wait briefly or fail predictably.
- Write a payload containing explicit freshness timestamps, with a randomized Redis expiry longer than the stale window.
Store logical freshness inside the value instead of treating the Redis TTL as the entire policy. Redis expiry then becomes cleanup; the application can distinguish fresh, stale-but-servable, and absent data.
Implement request coalescing and stale-while-revalidate in Node.js
The following TypeScript sketch uses the official node-redis client. It intentionally separates the core pattern from framework routing. Validate the origin result before caching it, use a schema-versioned key, and never cache an authorization result under a key that omits the user or tenant boundary.
type CacheEnvelope<T> = {
value: T;
freshUntil: number;
staleUntil: number;
};
const inFlight = new Map<string, Promise<unknown>>();
function singleFlight<T>(key: string, work: () => Promise<T>): Promise<T> {
const running = inFlight.get(key) as Promise<T> | undefined;
if (running) return running;
const promise = work().finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}
function jitter(seconds: number): number {
return Math.max(1, Math.round(seconds * (0.9 + Math.random() * 0.2)));
}
async function loadCached<T>(
key: string,
origin: () => Promise<T>,
freshSeconds = 60,
staleSeconds = 300,
): Promise<T> {
const raw = await redis.get(key);
const cached = raw ? JSON.parse(raw) as CacheEnvelope<T> : null;
const now = Date.now();
if (cached && now < cached.freshUntil) return cached.value;
const refresh = () => singleFlight(key, async () => {
const value = await origin();
const writtenAt = Date.now();
const envelope: CacheEnvelope<T> = {
value,
freshUntil: writtenAt + freshSeconds * 1000,
staleUntil: writtenAt + staleSeconds * 1000,
};
await redis.set(key, JSON.stringify(envelope), {
EX: jitter(staleSeconds + 60),
});
return value;
});
if (cached && now < cached.staleUntil) {
void refresh().catch(error => reportRefreshFailure(key, error));
return cached.value;
}
return refresh();
}The map deduplicates only within one process, but that is still valuable and cheap. Use a bounded map or a library with equivalent cleanup if keys are attacker-controlled; otherwise an unbounded key space can become a memory-exhaustion vector. The background refresh must surface rejected promises to telemetry, and shutdown should stop accepting new work before closing Redis—consistent with a graceful Node.js shutdown.
TTL jitter spreads expirations over time. The range above is an example, not a universal constant. Choose it from freshness tolerance and workload shape, then confirm the resulting origin traffic in load tests.
Coordinate refreshes across replicas with a bounded Redis lease
When the origin operation is expensive, acquire a per-key lease before refreshing. Redis documents the single-instance primitive as SET key token NX PX duration: NX creates the lease only when absent and PX gives it an expiry. The token must be unique per acquisition. Release only when the stored token still matches, so an old worker cannot delete a newer worker's lease. See Redis's distributed-lock guidance and SET reference.
const token = crypto.randomUUID();
const leaseMs = 5_000;
const acquired = await redis.set(lockKey, token, { NX: true, PX: leaseMs });
if (acquired === "OK") {
try {
return await refreshFromOrigin();
} finally {
await redis.eval(
'if redis.call("get", KEYS[1]) == ARGV[1] then ' +
'return redis.call("del", KEYS[1]) else return 0 end',
{ keys: [lockKey], arguments: [token] },
);
}
}The lease duration must exceed the expected refresh time with margin, but every duration can expire during a slow origin call, event-loop pause, failover, or network partition. For cache regeneration that means occasional duplicate work—usually acceptable if writes are idempotent. Do not reuse this lightweight cache lease for money movement, inventory allocation, or another correctness-critical workflow. Those need a design with database constraints, idempotency, and fencing or transactional serialization.
A caller that loses the lease should not spin aggressively. Serve stale data when allowed; otherwise retry the cache read with capped exponential backoff and random delay, bounded by the request deadline. If the value is still absent, fail with a controlled response or use tightly limited origin concurrency. Never wait longer than the caller can benefit from the result.
Design invalidation, keys, and memory as part of the architecture
Version keys instead of deleting an unknown universe
Use keys such as catalog:v3:tenant:42. A schema or serialization change can advance the version without mixing incompatible payloads. Include every dimension that changes the answer—tenant, locale, permissions, query filters—but hash or normalize large untrusted inputs and enforce a maximum cardinality.
Invalidate after the source of truth commits
For write-through invalidation, update the database first and delete or replace the cache only after commit. If a crash between those actions is unacceptable, publish an invalidation through a transactional outbox. The cache remains disposable; the database remains authoritative. Avoid broad wildcard deletion on a request path.
Configure Redis for cache memory behavior
Set a memory limit and select an eviction policy from measured access patterns. Redis documents that maxmemory bounds cache data and the configured policy decides what happens beyond it; INFO stats exposes hits, misses, expired keys, and evictions. Review the official Redis eviction guidance. Separate durable Redis data from disposable cache entries when possible so one eviction policy does not serve conflicting purposes.
HTTP-facing caches may also use the standardized stale-while-revalidate and stale-if-error cache-control extensions defined by RFC 5861. That browser or CDN behavior complements application caching; it does not replace per-resource authorization and origin coordination.
Measure whether the cache protects the origin
A high hit ratio can hide a severe stampede on one hot key. Instrument the complete decision path with low-cardinality labels:
- fresh hits, stale hits, hard misses, bypasses, and parse failures;
- single-flight joins, lease wins, lease contention, wait duration, and waiter timeouts;
- refresh latency, result size, failure rate, and origin calls per logical key;
- Redis command latency, reconnects, timeouts, memory, evictions, and expired keys;
- database latency, pool saturation, error rate, and query concurrency during misses.
Never attach raw cache keys containing user IDs, search queries, or secrets to metric labels. Log a safe namespace and sampled hash when key-level diagnosis is necessary. Alert on changes in origin amplification and stale age, not only Redis availability. A cache outage should degrade according to a rehearsed policy: limited origin traffic, safe stale data, or a controlled error—not an uncontrolled bypass by every replica.
For request-level correlation across cache, application, and origin calls, apply the techniques in our request tracing and structured logging guide. Teams designing a new service can also involve Endurance Softwares for repository-supported custom software development and cloud infrastructure engineering, from cache policy through deployment and operational validation.
Test the herd, not just the happy-path hit
A unit test that performs one miss cannot prove stampede control. Use deterministic clocks where possible and test each state transition: fresh, stale, expired, evicted, malformed, and unavailable. Then run concurrent integration tests with a counted origin stub.
- Send many concurrent requests for one absent key and assert the allowed origin-call bound.
- Repeat across multiple application processes to exercise the Redis lease.
- Make the origin slower than the lease and verify duplicate refreshes do not corrupt data.
- Crash the lease holder; confirm expiry enables recovery and waiters remain bounded.
- Disconnect Redis during a refresh and verify the chosen stale or failure behavior.
- Expire thousands of keys together, then repeat with TTL jitter and compare origin concurrency.
- Return invalid, oversized, and sensitive origin payloads; confirm they are rejected or safely handled.
- Exercise tenant and permission boundaries to prove one identity cannot receive another's cached result.
Load-test with a skewed key distribution; uniform random requests miss the real risk because production traffic often concentrates on a small hot set. Record p95/p99 response latency, maximum origin concurrency, stale-serving duration, and recovery time rather than declaring success from throughput alone.
Redis cache stampede prevention checklist
✓ Define freshness and stale-on-error rules per data class
✓ Version keys and include tenant and authorization dimensions
✓ Coalesce duplicate work inside each Node.js process
✓ Add TTL jitter to spread synchronized expiry
✓ Serve stale data only inside an explicit safe window
✓ Use unique lease tokens and ownership-safe release
✓ Bound lease waits, retries, refreshes, and origin concurrency
✓ Configure and monitor Redis memory and eviction
✓ Validate payloads before caching and cap key cardinality
✓ Test cold start, failure, contention, expiry, and recovery
Build caching as a reliability feature
The right cache strategy follows the data's correctness rules, traffic shape, and failure budget. Endurance Softwares can help design and implement Node.js APIs, Redis-backed platforms, and production observability without turning caching into hidden operational debt.
Discuss your application architecture