CacheInterceptor TTL Expiration and Cache Stampede Prevention
0 reputation · 06 Oct 2023, 21:49 UTC
0 reputation · 06 Oct 2023, 21:49 UTC
NestJS provides the CacheInterceptor to optimize response times by caching results based on a defined Time-To-Live (TTL). While this effectively reduces load for static or semi-static data, the default behavior triggers a cache miss immediately upon TTL expiration.
In high-throughput environments, this creates a risk where multiple concurrent requests for the same expired key bypass the cache simultaneously. This behavior can lead to a cache stampede, placing unexpected pressure on the underlying data provider or database until the first request repopulates the cache.
Given that CacheInterceptor does not natively implement a stale-while-revalidate pattern or request collapsing, how can the system be configured to prevent redundant provider calls during a TTL expiration event? Is there a recommended configuration to ensure only one request refreshes the cache while others receive a stale response?
35275 reputation · 07 Oct 2023, 05:08 UTC
To prevent a cache stampede when the TTL expires, wrap the CacheInterceptor logic with a per‑key lock (or use a cache that supports refresh‑after‑write) so only one request reloads the value while others receive the stale entry.
Without native stampede protection, the interceptor treats an expired entry as a miss and lets every concurrent request call the provider. Adding a lightweight lock around the cache lookup ensures that only the first thread proceeds to reload; waiting threads either block on the lock or receive the stale value if you return it before releasing the lock.
cache-manager and optionally cache-manager-ioredis if you use Redis.CacheInterceptor (or re‑implements the intercept logic).AsyncMutex per cache key.intercept:next.handle(), stores the result in the cache, and resolves.nestjs/cache-interceptor (or cache-manager) and confirm that after TTL expiry only one miss → store sequence appears per key.cache-manager stats and verify that miss spikes are bounded.Are you using the default in‑memory store or a distributed store such as Redis? The locking approach differs: a local async mutex works for a single‑node memory cache, while a distributed lock (e.g., Redisson RLock) is needed for multi‑node Redis deployments.
Use comments to ask for clarification. Post a solution as an answer.
5,960 reputation · 07 Oct 2023, 02:13 UTC
Instead of writing a custom lock, you can use cache.wrap from cache‑manager. It atomically checks for a key, acquires a temporary lock, runs the factory once, and returns the cached value to all callers.
async getData(id: string) {
const key = `user:${id}`;
return this.cache.wrap(key, async () => {
const fresh = await this.db.find(id);
return fresh;
}, { ttl: 60, stale: true }); // stale:true keeps old value until refresh completes
}
When the store is Redis (via cache-manager-redis-store ≥2.0), the stale flag is honored automatically, so concurrent requests after TTL expiry get the stale entry while the first call refreshes the cache. If you need finer control, pass a custom redisStore with stale:true in CacheModule.register.
Testing: fire 10 parallel calls after TTL expires and verify the provider runs only once.