| name | caching-patterns |
| description | Redis caching strategies, cache invalidation, write-through/write-behind, TTL management, and cache stampede protection. |
Caching Patterns
Redis-based caching strategies for reducing latency and database load.
Cache Key Design
const CacheKeys = {
market: (id: string) => `market:v1:${id}`,
marketList: (filters: string) => `market:list:${filters}`,
user: (id: string) => `user:v1:${id}`,
userMarkets: (userId: string, page: number) => `user:${userId}:markets:${page}`,
leaderboard: () => 'leaderboard:v1:global'
}
Cache-Aside (Lazy Loading)
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
const DEFAULT_TTL = 300
async function getOrSet<T>(
key: string,
loader: () => Promise<T>,
ttl = DEFAULT_TTL
): Promise<T> {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached) as T
const value = await loader()
await redis.setex(key, ttl, JSON.stringify(value))
return value
}
async function getMarket(id: string): Promise<Market> {
return getOrSet(
CacheKeys.market(id),
() => db..({ : { id } }),
)
}
Write-Through Pattern
async function updateMarket(id: string, data: UpdateMarketDto): Promise<Market> {
const updated = await db.market.update({ where: { id }, data })
await redis.setex(CacheKeys.market(id), DEFAULT_TTL, JSON.stringify(updated))
return updated
}
async function deleteMarket(id: string): Promise<void> {
await db.market.delete({ where: { id } })
await redis.del(CacheKeys.market(id))
}
Write-Behind (Write-Back) Pattern
class WriteBehindCache {
private dirtyKeys = new Set<string>()
private flushInterval: NodeJS.Timeout
constructor(private flushEveryMs = 1000) {
this.flushInterval = setInterval(() => this.flush(), flushEveryMs)
}
async write(key: string, value: unknown, dbWriter: () => Promise<void>): Promise<void> {
await redis.setex(key, DEFAULT_TTL, JSON.stringify(value))
this.dirtyKeys.add(key)
dbWriter().catch(err => {
console.error(, err)
..(key)
})
}
(): <> {
..()
}
(): {
(.)
}
}
Cache Stampede Protection
import { Mutex } from 'async-mutex'
const mutexMap = new Map<string, Mutex>()
function getMutex(key: string): Mutex {
if (!mutexMap.has(key)) {
mutexMap.set(key, new Mutex())
setTimeout(() => mutexMap.delete(key), 30_000)
}
return mutexMap.get(key)!
}
async function getWithMutex<T>(
key: string,
loader: () => Promise<T>,
ttl = DEFAULT_TTL
): Promise<T> {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached) as T
const mutex = getMutex(key)
return mutex.( () => {
rechecked = redis.(key)
(rechecked) .(rechecked) T
value = ()
redis.(key, ttl, .(value))
value
})
}
getWithEarlyExpire<T>(
: ,
: <T>,
ttl = ,
beta =
): <T> {
raw = redis.(key)
(raw) {
{ value, expires } = .(raw) { : T; : }
ttlRemaining = (expires - .()) /
(ttlRemaining - beta * .(.()) > ) {
value
}
}
value = ()
payload = { value, : .() + ttl * }
redis.(key, ttl, .(payload))
value
}
Multi-Level Caching (L1 Memory + L2 Redis)
import LRU from 'lru-cache'
const l1 = new LRU<string, unknown>({
max: 500,
ttl: 30_000
})
async function getMultiLevel<T>(
key: string,
loader: () => Promise<T>,
l2Ttl = DEFAULT_TTL
): Promise<T> {
const l1Hit = l1.get(key) as T | undefined
if (l1Hit !== undefined) return l1Hit
const l2Hit = await redis.get(key)
if (l2Hit) {
const value = JSON.parse(l2Hit) as T
l1.set(key, value)
return value
}
const value = await loader()
l1.set(key, value)
await redis.setex(key, l2Ttl, .(value))
value
}
(): <> {
l1.(key)
redis.(key)
}
Event-Based Cache Invalidation
import { EventEmitter } from 'events'
const cacheEvents = new EventEmitter()
async function resolveMarket(id: string, outcome: string): Promise<void> {
await db.market.update({ where: { id }, data: { status: 'resolved', outcome } })
cacheEvents.emit('market:updated', id)
}
cacheEvents.on('market:updated', async (id: string) => {
await redis.del(CacheKeys.market(id))
const listKeys = await redis.keys('market:list:*')
if (listKeys.length) await redis.del(...listKeys)
})
Cache Warming
async function warmCache(): Promise<void> {
console.log('Warming cache...')
const topMarkets = await db.market.findMany({
take: 100,
orderBy: { volume: 'desc' }
})
const pipeline = redis.pipeline()
for (const market of topMarkets) {
pipeline.setex(CacheKeys.market(market.id), 3600, JSON.stringify(market))
}
await pipeline.exec()
console.log(`Cache warmed: ${topMarkets.length} markets`)
}
app.on('ready', warmCache)
Monitoring Cache Health
async function getCacheStats(): Promise<{
hitRate: number
memoryUsed: string
connectedClients: number
keyCount: number
}> {
const info = await redis.info('stats')
const memory = await redis.info('memory')
const clients = await redis.info('clients')
const hits = parseInt(info.match(/keyspace_hits:(\d+)/)?.[1] || '0')
const misses = parseInt(info.match(/keyspace_misses:(\d+)/)?.[1] || '0')
const total = hits + misses
return {
hitRate: total > 0 ? hits / total : 0,
memoryUsed: memory.match(/used_memory_human:(.+)/)?.[1]?.trim() || 'unknown',
connectedClients: parseInt(clients.match(/connected_clients:(\d+)/)?.[] || ),
: redis.()
}
}
( () => {
stats = ()
(stats. < ) {
.()
}
}, )
Common Pitfalls
Cache penetration: requests for non-existent keys bypass cache every time
→ Cache null results with short TTL (30s)
Thundering herd: many requests hit DB simultaneously on cache expiry
→ Use mutex lock or probabilistic early expiration
Stale data: cache serves outdated values after DB update
→ Use write-through or event-based invalidation, not only TTL
Hot key: single cache key gets millions of requests/sec
→ Shard into multiple keys or replicate across Redis cluster
Big value: storing 10MB JSON in a single key blocks Redis
→ Compress with msgpack, split into smaller units, use streaming
Remember: Cache is eventually consistent by design. Design your system to tolerate brief staleness, and use invalidation events for correctness-critical data.