Redis Caching Strategies for Node.js: From 50ms to 5ms
Master Redis caching to reduce database load and latency. This guide covers cache strategies, invalidation, serialization, and production patterns for high-scale Node.js applications.

Caching is the single most effective performance optimization. A well-configured Redis cache can reduce database queries by 80-95%, turning 50ms responses into 5ms. But naive caching leads to stale data, memory bloat, and cache stampedes. This guide walks through battle-tested patterns from production systems handling 100k+ requests per second.
Setup: Redis Connection Pooling
Never create a new Redis connection per request. Use a singleton with ioredis:
import Redis from 'ioredis';
class RedisClient {
static instance;
static getInstance() {
if (!this.instance) {
this.instance = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
password: process.env.REDIS_PASSWORD,
retryStrategy: (times) => Math.min(times * 50, 2000),
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: true,
});
}
return this.instance;
}
}
export const redis = RedisClient.getInstance();
Pattern #1: Cache-Aside (Lazy Loading)
Most common pattern. Application checks cache first, falls back to database, then writes to cache:
async function getUserById(id) {
const cacheKey = `user:${id}`;
// 1. Try cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 2. Cache miss - get from DB
const user = await db.query(‘SELECT * FROM users WHERE id = $1’, [id]);
// 3. Store in cache with TTL (5 minutes)
await redis.setex(cacheKey, 300, JSON.stringify(user));
return user;
}
Pros: Simple, works for read-heavy workloads.
Cons: First request after cache expiry is slow (cache stampede).
Pattern #2: Write-Through (Update Cache on Write)
When data updates, update both database and cache simultaneously:
async function updateUser(id, data) {
// 1. Update database
const updatedUser = await db.query(
'UPDATE users SET name = $1 WHERE id = $2 RETURNING *',
[data.name, id]
);
// 2. Update cache synchronously
const cacheKey = user:${id};
await redis.setex(cacheKey, 300, JSON.stringify(updatedUser.rows[0]));
// 3. Invalidate related caches
await redis.del(user-profile:${id});
return updatedUser.rows[0];
}
Pattern #3: Cache Stampede Prevention (Mutex Lock)
When many requests simultaneously miss a just-expired cache, they all hit the database. Use Redis distributed lock to let only one request recompute:
async function getProductWithMutex(productId) {
const cacheKey = `product:${productId}`;
const lockKey = `lock:product:${productId}`;
const ttl = 60; // 1 minute cache
// Try cache first
let cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Try to acquire lock (NX = only if not exists, PX = expire in ms)
const lockAcquired = await redis.set(lockKey, ‘locked’, ‘NX’, ‘PX’, 5000);
if (lockAcquired) {
// This process recomputes
const product = await db.query(‘SELECT * FROM products WHERE id = $1’, [productId]);
await redis.setex(cacheKey, ttl, JSON.stringify(product));
await redis.del(lockKey);
return product;
} else {
// Other processes wait and retry
await sleep(100);
return getProductWithMutex(productId); // Recursive retry
}
}
Pattern #4: Bulk/Batch Caching with Redis Pipeline
For APIs that return lists (e.g., 100 products), use pipelining to reduce round trips:
async function getProductsByIds(ids) {
const pipeline = redis.pipeline();
const keys = ids.map(id => `product:${id}`);
keys.forEach(key => pipeline.get(key));
const results = await pipeline.exec();
const cachedProducts = results
.map(([err, data]) => err || !data ? null : JSON.parse(data))
.filter(p => p !== null);
const missingIds = ids.filter((id, idx) => !cachedProducts[idx]);
if (missingIds.length > 0) {
const dbProducts = await db.query(‘SELECT * FROM products WHERE id = ANY($1)’, [missingIds]);
// Cache missing products
const multi = redis.multi();
dbProducts.forEach(p => multi.setex(product:${p.id}, 300, JSON.stringify(p)));
await multi.exec();
return [...cachedProducts, ...dbProducts];
}
return cachedProducts;
}
Serialization: JSON vs MessagePack
JSON is human-readable but verbose. For large payloads, use MessagePack (30% smaller, 50% faster):
import msgpack from '@msgpack/msgpack';
// Encode
const buffer = msgpack.encode(largeObject);
await redis.set(‘key’, buffer);
// Decode
const buffer = await redis.getBuffer(‘key’);
const obj = msgpack.decode(buffer);
Cache Invalidation Strategies
The hardest problem in caching. Common patterns:
- Time-based (TTL): Simple, predictable staleness. Good for product catalogs, weather data.
- Event-based invalidation: Emit events (e.g., 'user.updated') and have cache subscriber delete keys.
- Versioned keys:
user:v1:123, increment version on schema change. - Tag-based (Redis 6.2+): Store tags in a Set, delete all keys with a tag on update.
// Tag-based invalidation example
async function cacheWithTags(key, tags, data, ttl) {
await redis.setex(key, ttl, JSON.stringify(data));
for (const tag of tags) {
await redis.sadd(`tag:${tag}`, key);
}
}
async function invalidateByTag(tag) {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length) await redis.del(...keys);
await redis.del(`tag:${tag}`);
}
// Usage
await cacheWithTags('product:123', ['product', 'category:electronics'], productData, 3600);
await invalidateByTag('category:electronics'); // Deletes all products in category
Memory Management: Eviction Policies
Redis memory is finite. Configure maxmemory and policy in redis.conf:
maxmemory 2gb
maxmemory-policy allkeys-lru # Least Recently Used (best for caches)
# Alternatives: volatile-lru, allkeys-random, volatile-ttl, noeviction
Monitor memory with redis-cli INFO memory and redis-cli --stat.
Common Pitfalls
- Hot keys: A single popular key (e.g., trending product) can overwhelm Redis. Replicate across shards or use client-side caching.
- Big keys: Redis operations on keys > 10MB are slow. Break into hash structures or compress before storing.
- Cache avalanche: Many keys expiring simultaneously. Add jitter to TTLs:
ttl + Math.random() * 60. - No monitoring: Track hit/miss ratio. Aim for >80% hit rate for effective caching.
Conclusion
Redis caching transforms database-bound applications. Start with cache-aside for individual entities, add write-through for consistency, and implement stampede protection for high-traffic endpoints. Monitor hit ratios and adjust TTLs accordingly. With proper serialization and eviction policies, you can reduce database load by 90% and deliver sub-10ms responses at scale.
Comments
Join the conversation — sign in to leave a comment.