Cache Stampedes in Node.js: Why a Redis Cache Can Overload Your Database
Learn why a naive cache-aside Redis setup synchronizes load onto your database when a hot key expires, and how locks and background refresh prevent it.
This article is published in English.
Sources: “I Thought Redis Would Make My API Faster.” It Didn’t.
Adding Redis in front of a slow query is the textbook fix for a database under pressure, and on a quiet day it looks like a clear win. Under peak traffic, though, a simple cache-aside setup can make things dramatically worse: the moment a popular key expires, every concurrent request misses at once and hammers the database together. This article explains that failure, known as a cache stampede, traces how it cascades through a Node.js API, and shows two patterns that prevent it: a distributed regeneration lock and background pre-computation.
A fix that works until peak traffic
The usual setup is cache-aside. On each request the API looks in Redis; on a miss it queries the database and writes the result back to Redis for next time. Deployed on a low-traffic day, it cuts latency immediately.
Then the Monday morning rush arrives and the API stops responding. The Node.js process shows low CPU, so the event loop is not the suspect. The primary database, however, is pinned at 100% CPU.
Redis is not the bottleneck
Redis itself is rarely the problem. It serves hundreds of thousands of operations per second with sub-millisecond latency. What causes trouble is the relationship between the application and the cache, specifically how the application behaves when data is missing.
A common mental model is a quiet library: you ask the front desk (Redis), and if the book is not there, a librarian (the API) fetches it from the stacks (PostgreSQL) and leaves a copy at the desk. At scale the setting looks more like a trading floor. When thousands of clients ask for the same popular item at the instant its cache entry expires, they all find the desk empty at the same time, and they all head for the stacks.
Anatomy of a stampede
Take an endpoint such as GET /products/trending. Building its response involves several joins, subqueries and some aggregation in Node.js, and it takes about 1.2 seconds. The result is cached for ten minutes.
In steady state this is very efficient: out of ten thousand requests, one reaches the database and the rest are served from Redis in around 5 ms.
Now suppose the ten-minute TTL runs out at 09:00:00. At 09:00:01, fifty requests arrive concurrently. Every one of them misses, and every one of them starts the same 1.2-second query. The database suddenly runs fifty heavy queries in parallel. The connection pool is exhausted almost instantly, and every other API request that needs the database, even for unrelated data, now queues behind those fifty.
It is worth being precise here: waiting for a pooled connection does not block the Node.js event loop, because those waits are asynchronous. What happens instead is that requests pile up as pending promises and open sockets, which is why the process looks idle while users see timeouts.
This is the cache stampede, also called the dogpile effect. The cache has not reduced load; it has synchronized a burst of load onto a single moment.
Why it cascades instead of recovering
A stampede is not simply a slow period. It feeds on itself.
- Query times explode. The 1.2-second query can stretch to 25 seconds once fifty copies compete for CPU, locks and I/O.
- API instances hold thousands of open HTTP connections while they wait, consuming memory and file descriptors.
- CPU on the API tier stays low, so an autoscaler keyed on CPU may never add capacity, and adding API instances would only send more queries to the database anyway.
- New requests keep arriving and missing, because the slow queries have not yet written a value back. Eventually the database fails outright through memory exhaustion or connection timeouts.
Preventing stampedes
Adding a faster store cannot fix what is really a coordination problem. The remedy is to change who is allowed to regenerate expensive data, and when.
Pattern 1: a distributed regeneration lock
When an API runs as several instances, regeneration has to be coordinated across all of them, which means a lock stored in Redis. The first request that misses the cache tries to take the lock before querying the database. Any other request that misses while the lock is held stays away from the database and does one of two things:
- Waits and retries: sleeps briefly, for example 50 ms, and checks Redis again until the new value appears.
- Serves stale data: returns the previous value, if the product can tolerate slightly old results.
The example below uses ioredis. It first reads the cache. On a miss it tries SET with NX and PX 5000, which creates the lock only if nobody else holds it and makes it expire after five seconds. The winner runs the query, stores the result for 600 seconds and releases the lock in a finally block; everyone else polls until the value is populated.
// Example: Using Redis for a locking/syncing pattern (Conceptual)
import { Redis } from 'ioredis';
const redis = new Redis();
async function getCachedTrendingProducts() {
const cacheKey = 'products:trending';
const lockKey = 'lock:products:trending';
// Attempt 1: Get data
let data = await redis.get(cacheKey);
if (data) return JSON.parse(data);
// Attempt 2: Cache miss. Try to acquire the regeneration lock.
const acquiredLock = await redis.set(lockKey, 'locked', 'NX', 'PX', 5000); // 5s lock
if (acquiredLock) {
// We hold the lock. We are the ONLY worker hitting the DB.
try {
// 1.2s Database Operation
const dbData = await fetchTrendingProductsFromDB();
// Store in Redis (10 min TTL)
await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
return dbData;
} finally {
// Release lock
await redis.del(lockKey);
}
} else {
// We did NOT get the lock. Someone else is already regenerating.
// Wait and Retry (e.g., exponential backoff or simple polling)
return pollRedisUntilPopulated(cacheKey);
}
}
The effect is that at most one expensive query per key reaches the database at a time, however many requests arrive concurrently.
A few refinements are worth considering in production:
- The lock expiry must exceed the worst-case query time. If regeneration takes longer than five seconds, the lock lapses and a second worker starts a duplicate query.
- Deleting the lock unconditionally can remove a lock that another worker acquired after yours expired. Storing a unique token as the lock value and deleting only if it still matches, usually with a small Lua script, avoids that.
pollRedisUntilPopulatedneeds a timeout and a fallback, otherwise waiting requests can hang if the lock holder crashes.- Within a single Node.js process you can also coalesce concurrent misses by sharing one in-flight promise per key, which cuts the number of lock attempts before Redis is even involved.
Pattern 2: pre-compute in the background
The most robust approach separates delivering data from generating it. Request handlers, the synchronous path, only ever read from the cache and never rebuild it. If a value is missing, they return an empty or "loading" state immediately, which is far better than tying up a connection for seconds.
Regeneration moves to a background process, such as a cron job, a BullMQ worker or a change-data-capture consumer, that refreshes the key before it expires. In the example below, a worker scheduled every nine minutes rebuilds the trending list and writes it back with the same ten-minute TTL, so the key never goes cold under normal operation:
// Separate background worker file
// For example, running every 9 minutes via Cron or BullMQ
async function regenerateTrendingProductsCache() {
const cacheKey = 'products:trending';
try {
// Proactive DB fetch (1.2s)
const dbData = await fetchTrendingProductsFromDB();
// Refresh the 10-minute cache
await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
console.log('Cache proactively refreshed.');
} catch (err) {
console.error('Proactive refresh failed:', err);
}
}
Because incoming requests always find a warm key, there is no moment at which they can collectively fall through to the database. Two details keep this safe: run the refresh on a single scheduler or behind a lock so multiple instances do not all refresh at once, and alert when the refresh fails, since the catch block alone only logs the error and the key will expire a minute later. For the scheduling side, the guide to reliable background jobs with BullMQ and Redis goes deeper.
Choosing between them
Locking keeps the familiar cache-aside flow and suits data that is requested unpredictably. Background pre-computation suits a small set of known, expensive, frequently read keys, like a trending list or a dashboard summary, and gives the most predictable latency. Adding a little random jitter to TTLs also helps when many keys were written at the same time and would otherwise expire together.
Key takeaways
- Redis is fast; stampedes come from how the application reacts to a miss, not from the cache itself.
- Naive cache-aside turns a single expiry into a synchronized burst against your slowest dependency, and the failure can hide behind low API CPU.
- A distributed lock ensures only one caller regenerates a key; handle lock expiry, ownership and waiting timeouts carefully.
- Background refresh removes regeneration from the request path entirely and is the strongest option for hot, expensive keys.
- Treat caching as an architectural decision about protecting the database under load, not as a quick speed boost.