Overview
Caching trades a small amount of staleness for a large amount of speed: instead of recomputing or re-fetching data on every request, a fast in-memory store like Redis serves recent results directly. The pattern you choose determines who is responsible for keeping the cache in sync with the source of truth, and how staleness shows up. Most systems default to cache-aside because it is simple and fails safe (a cache outage just means every request falls through to the database), reaching for write-through or write-behind only when read-after-write consistency or write-latency shaping specifically matters.
Quick Reference
| Pattern | Reads | Writes | Trade-off |
|---|---|---|---|
| Cache-aside | Check cache, fall through to DB on miss, populate cache | DB only | Simple, cache can go stale between writes |
| Write-through | Always from cache | Cache + DB together, synchronously | Cache always fresh, extra write latency |
| Write-behind | Always from cache | Cache immediately, DB asynchronously | Fastest writes, risk of data loss on crash before flush |
| Read-through | Cache library itself fetches on miss | N/A | Same as cache-aside, but the cache layer owns the fetch logic |
Syntax
// Cache-aside with ioredis
async function getUser(id) {
const cacheKey = `user:${id}`
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
const user = await db.users.findById(id)
await redis.set(cacheKey, JSON.stringify(user), 'EX', 300) // 5 minute TTL
return user
}Examples
// Stampede protection: a short-lived lock so only one request
// repopulates a hot key while others wait briefly.
async function getWithLock(key, fetchFn, ttlSeconds = 300) {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const lockKey = `lock:${key}`
const gotLock = await redis.set(lockKey, '1', 'NX', 'PX', 3000)
if (!gotLock) {
await new Promise((r) => setTimeout(r, 50))
return getWithLock(key, fetchFn, ttlSeconds) // brief retry
}
try {
const value = await fetchFn()
await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds)
return value
} finally {
await redis.del(lockKey)
}
}// Invalidate-after-write, correctly ordered: DB first, then cache.
async function updateUser(id, changes) {
await db.users.update(id, changes)
await redis.del(`user:${id}`)
}Visual Diagram
Common Mistakes
- No TTL at all, an unbounded cache key that never expires becomes a silent source of stale data with no automatic recovery path.
- Invalidating the cache before writing the database, opening a window for a concurrent read to re-cache the old value.
- Caching without a stampede guard on high-traffic keys, a single popular key expiring under load can spike database connections.
- Using overly long TTLs to "reduce database load" without considering how stale the data is allowed to get for that specific use case.
- Storing large objects in a single key instead of normalizing; this makes partial invalidation impossible and wastes memory on unused fields.
Performance
- Redis operations are single-threaded per command but extremely fast (sub-millisecond) for simple key lookups; the bottleneck is almost always network round-trips, not Redis itself, so batching with pipelining or
MGETmatters more than micro-optimizing individual commands. - Jittered TTLs (
baseTTL + random(0, jitter)) spread out expiration so many keys set at the same time do not all expire simultaneously and cause a coordinated stampede. SCANshould always be used instead ofKEYSin production,KEYSblocks the single-threaded server while it walks the entire keyspace.
Best Practices
- Default to cache-aside; only reach for write-through/write-behind when you have a specific consistency or write-latency requirement that demands it.
- Always set a TTL, even a long one, as a safety net against unbounded staleness.
- Add stampede protection (locking or stale-while-revalidate) to any cache key that serves meaningfully hot traffic.
- Namespace cache keys clearly (
user:123,order:456:items) so invalidation and debugging stay tractable as the number of cached entities grows.