Caching Is Easy to Get Wrong
Adding a cache to a system is straightforward. Keeping that cache correct, handling failures gracefully, and avoiding the subtle failure modes that only emerge under load is significantly harder. This guide covers the production caching patterns that actually hold up, and the common mistakes that cause cache-related incidents.
Cache-Aside: The Standard Pattern
Cache-aside (lazy loading) is the most common pattern. The application checks the cache first, fetches from the database on a miss, and writes the result to the cache. The cache never contains data that hasn't been requested, which keeps memory usage efficient.
import { createClient } from 'redis'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
async function getUserById(userId: string) {
const cacheKey = `user:${userId}`
// 1. Try cache
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
// 2. Cache miss — fetch from DB
const user = await db.users.findUnique({ where: { id: userId } })
if (!user) return null
// 3. Write to cache with TTL
await redis.setEx(cacheKey, 300, JSON.stringify(user))
return user
}
Always set a TTL. A cache without TTLs eventually fills up with stale data and evicts fresh data. For most application data, 5–15 minutes is a reasonable default. For data that rarely changes (user preferences, product categories), 1–24 hours is appropriate.
Cache Invalidation on Write
When data changes, invalidate the cache immediately rather than waiting for TTL expiry. Stale cache reads after a write feel like bugs to users.
async function updateUser(userId: string, data: Partial) {
// Update database first
const updated = await db.users.update({
where: { id: userId },
data,
})
// Invalidate cache — force next read to hit DB
await redis.del(`user:${userId}`)
// Or: write-through — update cache immediately
await redis.setEx(`user:${userId}`, 300, JSON.stringify(updated))
return updated
}
The choice between delete (invalidate) and update (write-through) depends on your read/write ratio. For read-heavy data, write-through avoids the cache miss after a write. For write-heavy data where the updated value may not be read again soon, deletion is more efficient.
The Thundering Herd Problem
When a cached item expires and many concurrent requests all miss the cache simultaneously, they all hit the database at once. For expensive queries, this can overwhelm the database. Solve it with a distributed lock — only one request rebuilds the cache; others wait briefly and then hit the now-warm cache.
async function getExpensiveData(key: string) {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const lockKey = `lock:${key}`
const lockAcquired = await redis.set(lockKey, '1', {
NX: true, // only set if not exists
EX: 10, // lock expires after 10s to prevent deadlock
})
if (!lockAcquired) {
// Another process is rebuilding — wait briefly and retry
await new Promise(r => setTimeout(r, 100))
return getExpensiveData(key) // recursive retry
}
try {
const data = await expensiveDbQuery()
await redis.setEx(key, 300, JSON.stringify(data))
return data
} finally {
await redis.del(lockKey) // always release the lock
}
}
Distributed Locks for Critical Sections
Redis is the standard tool for distributed locks in Node.js applications. Use the Redlock algorithm for stronger guarantees across Redis cluster nodes.
import Redlock from 'redlock'
const redlock = new Redlock([redis], {
retryCount: 3,
retryDelay: 200,
})
async function processOrder(orderId: string) {
const lockResource = `order:${orderId}:processing`
await redlock.using([lockResource], 5000, async (signal) => {
// Only one process executes this block at a time
const order = await db.orders.findUnique({ where: { id: orderId } })
if (order.status !== 'pending') return // already processed
await chargePayment(order)
await db.orders.update({ where: { id: orderId }, data: { status: 'paid' } })
if (signal.aborted) throw new Error('Lock expired during processing')
})
}
Redis Data Structures Beyond String Caching
Most developers only use Redis for string key-value caching. The other data structures unlock powerful use cases:
- Sorted Sets: Leaderboards, rate limiting with sliding windows, scheduling delayed jobs
- Sets: Tracking unique visitors, user session IDs, feature flag recipients
- Lists: Message queues, activity feeds, recent items
- Hashes: User session data, shopping carts — more memory-efficient than separate string keys
// Rate limiting with sorted set (sliding window)
async function isRateLimited(userId: string, limit: number, windowMs: number) {
const now = Date.now()
const key = `ratelimit:${userId}`
const pipe = redis.multi()
pipe.zRemRangeByScore(key, 0, now - windowMs) // remove old entries
pipe.zAdd(key, { score: now, value: now.toString() })
pipe.zCard(key) // count requests in window
pipe.expire(key, Math.ceil(windowMs / 1000))
const results = await pipe.exec()
const requestCount = results[2] as number
return requestCount > limit
}
Production Checklist
- Enable Redis persistence (AOF with
appendfsync everysec) or accept that a restart loses your cache and database hits will spike - Set
maxmemoryand a sensible eviction policy (allkeys-lrufor pure cache,volatile-lruif some keys should never be evicted) - Monitor
keyspace_hitsandkeyspace_misses— a hit rate below 90% means your TTLs are too short or your access patterns don't benefit from caching - Use connection pooling — never create a new Redis connection per request
- Implement circuit breakers: if Redis is unavailable, fall through to the database rather than failing the request entirely