Preventing Cache Stampedes with Singleflight & Multi-Tier Redis: Slashing P99 Latency to 22ms
Eliminating the thundering herd problem on SubsDrop's product catalog endpoints when thousands of concurrent users refresh popular subscription items.
Executive Summary & Scope
When digital subscription batches like Canva Pro or ChatGPT Plus drop on SubsDrop, thousands of users hit catalog endpoints simultaneously. If a cache key expires, naive systems suffer catastrophic cache stampedes. Here is how we engineered singleflight request deduplication with tiered caching.
Table of Contents (4 sections)▼
1. Anatomy of a Cache Stampede
Imagine a catalog item like 'ChatGPT Plus Subscription' cached in Redis with a 10-minute TTL. While valid, responses return in ~2ms.
At minute 10:01, the key expires. In that exact millisecond, 400 concurrent incoming HTTP requests check Redis, find a cache miss, and all 400 requests trigger identical, heavy MongoDB aggregation queries simultaneously.
This classic 'cache stampede' (or thundering herd) saturates the database connection pool, pegs database CPU to 95%, and causes response latencies to spike from 2ms to 240ms+.
2. The L1 Memory + L2 Redis Architecture
To permanently solve this, we architected a two-tier caching fabric:
• L1 In-Memory LRU Cache: Lives inside each Node.js process with an ultra-short 15-second TTL. This resolves in <0.1ms without any network TCP round-trip to Redis.
• L2 Distributed Redis Cache: Shared across our cluster processes with a 10-minute TTL.
• In-Flight Promise Registry: A thread-safe Promise latch that ensures only one single query executes against the database on a cache miss.
3. Implementing Singleflight in TypeScript
Below is our production-tested singleflight pattern implemented in TypeScript:
import Redis from "ioredis";
import { LRUCache } from "lru-cache";
const redis = new Redis(process.env.REDIS_URL!);
const localLru = new LRUCache<string, any>({
max: 1000,
ttl: 1000 * 15, // 15-second L1 memory cache
});
const inFlightRegistry = new Map<string, Promise<any>>();
export async function fetchTieredWithSingleFlight<T>(
cacheKey: string,
dbFallback: () => Promise<T>,
redisTtlSec = 600
): Promise<T> {
// 1. Check L1 Memory Cache (<0.1ms)
const l1Hit = localLru.get(cacheKey);
if (l1Hit) return l1Hit as T;
// 2. Check L2 Redis Cache (~2ms)
const l2Hit = await redis.get(cacheKey);
if (l2Hit) {
const parsed = JSON.parse(l2Hit);
localLru.set(cacheKey, parsed);
return parsed as T;
}
// 3. Singleflight Latch: If query already in flight, await existing Promise
if (inFlightRegistry.has(cacheKey)) {
return inFlightRegistry.get(cacheKey) as Promise<T>;
}
// 4. Execute single query and share with all concurrent callers
const queryPromise = (async () => {
try {
const freshData = await dbFallback();
await redis.setex(cacheKey, redisTtlSec, JSON.stringify(freshData));
localLru.set(cacheKey, freshData);
return freshData;
} finally {
// Clean up latch once resolved or rejected
inFlightRegistry.delete(cacheKey);
}
})();
inFlightRegistry.set(cacheKey, queryPromise);
return queryPromise;
}4. Benchmarking 2,500 Concurrent Hits
Using autocannon to simulate 2,500 concurrent connections hitting an expired catalog key:
• Without Singleflight: 2,500 DB queries triggered. DB CPU hit 92%. P99 latency: 284ms. 14 timed-out connections.
• With Singleflight: Exactly 1 DB query executed. DB CPU remained under 12%. P99 latency: 22ms. 0 dropped connections.
Share or Discuss this Field Note
Spread high-integrity engineering blueprints with other systems builders.