Database read operations are frequently the primary bottleneck in web applications. Integrating Redis as an in-memory caching layer dramatically reduces database load and lowers API latency from hundreds of milliseconds to sub-milliseconds.
However, improper caching implementations lead to stale data, memory exhaustion, and system outages during traffic spikes caused by Cache Stampedes (Thundering Herd Problem). This guide covers production-grade caching patterns and mitigation strategies in Node.js.
Core Caching Architecture Patterns
| Pattern | Mechanism | Pros | Cons |
|---|---|---|---|
| Cache-Aside (Lazy Loading) | Application reads from cache. On miss, reads from DB, updates cache, and returns result. | Resilient to cache failure; only requested data is cached. | Cache miss penalty on initial request; potential temporary stale data. |
| Write-Through | Application writes to cache first, and the cache synchronously writes to the DB. | Cache is always consistent with the database. | Higher write latency; unused data may pollute memory. |
| Write-Behind (Write-Back) | Application writes to cache; cache asynchronously persists to DB in background batches. | Ultra-fast write response times. | Risk of data loss if Redis crashes before async DB sync finishes. |
1. Implementing the Cache-Aside Pattern in Node.js
npm install ioredis
// services/userService.js
import Redis from 'ioredis';
import User from '../models/User.js';
const redis = new Redis(process.env.REDIS_URL);
const CACHE_TTL = 3600; // Time To Live: 1 Hour in seconds
export const getUserById = async (userId) => {
const cacheKey = `user:${userId}`;
// 1. Try reading from Redis Cache
const cachedUser = await redis.get(cacheKey);
if (cachedUser) {
return JSON.parse(cachedUser); // Cache Hit
}
// 2. Cache Miss: Fetch from Primary Database
const user = await User.findById(userId).lean();
if (!user) return null;
// 3. Populate Redis Cache with expiration time (TTL)
await redis.set(cacheKey, JSON.stringify(user), 'EX', CACHE_TTL);
return user;
};
2. Preventing Cache Stampede (Thundering Herd Problem)
A Cache Stampede occurs when a heavily requested cached key expires. Hundreds or thousands of concurrent incoming requests experience a cache miss simultaneously, all attempting to query the database and re-populate the cache at the same time, crashing the database.
Solution: Distributed Locking (Redlock Strategy)
Ensure that only one request queries the database on a cache miss while other concurrent requests wait or read the updated cache value.
// utils/fetchWithLock.js
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export const fetchWithLock = async (cacheKey, fetchDbFn, ttl = 3600) => {
// 1. Check cache
const cachedData = await redis.get(cacheKey);
if (cachedData) return JSON.parse(cachedData);
const lockKey = `lock:${cacheKey}`;
// Acquire an atomic lock with 5-second auto-release timeout (NX = Only if Not Exists)
const acquiredLock = await redis.set(lockKey, 'LOCKED', 'EX', 5, 'NX');
if (acquiredLock) {
try {
// Current thread won the lock -> Fetch from Database
const freshData = await fetchDbFn();
if (freshData) {
await redis.set(cacheKey, JSON.stringify(freshData), 'EX', ttl);
}
return freshData;
} finally {
// Release lock
await redis.del(lockKey);
}
} else {
// Other threads wait 100ms and retry reading from cache
await new Promise((resolve) => setTimeout(resolve, 100));
return fetchWithLock(cacheKey, fetchDbFn, ttl);
}
};
Redis Caching Best Practices
- Always Set Time-To-Live (TTL): Every cached key should have an explicit TTL set to prevent orphan keys from filling up memory.
- Set Eviction Policy: Configure Redis with
maxmemory-policy volatile-lruorallkeys-lruso old/unused keys are automatically evicted when memory capacity is reached. - Use Key Namespaces: Structure cache keys systematically using colon separators (e.g.,
tenant_id:entity:id) for clarity and easy key pattern management.