Complete guide to Redis caching patterns for Node.js in 2026: cache-aside, write-through, write-behind, TTL strategies, cache invalidation, pub/sub, and Redis Streams.
Redis caching is one of those things where the first 20% of the work gives you 80% of the benefit and the remaining 80% of the work prevents the 20% of cases that cause incidents. The cache-aside pattern is simple. Cache invalidation is famously hard. This guide covers both, plus write-through, write-behind, TTL strategies, cache warming, and the pub/sub patterns that let multiple services stay in sync.
Redis Client Setup for Production
Use ioredis for Node.js — it handles reconnection, cluster mode, Sentinel, and has better TypeScript support than the official redis package.
// src/cache/redis-client.ts
import Redis, { type RedisOptions } from 'ioredis'
import { logger } from '../utils/logger'
const options: RedisOptions = {
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
password: process.env.REDIS_PASSWORD,
db: Number(process.env.REDIS_DB ?? 0),
// Connection pool
maxRetriesPerRequest: 3,
retryStrategy: (times: number) => {
if (times > 10) return null // Stop retrying after 10 attempts
return Math.min(times * 100, 3000) // Exponential backoff, cap at 3s
},
// Timeouts
connectTimeout: 5000,
commandTimeout: 2000,
// Reconnect on error (e.g., READONLY error in replica failover)
reconnectOnError: (err: Error) => {
const targetErrors = ['READONLY', 'ECONNRESET', 'ETIMEDOUT']
return targetErrors.some((e) => err.message.includes(e))
},
lazyConnect: true,
enableAutoPipelining: true, // Batch commands automatically
}
export const redis = new Redis(options)
redis.on('connect', () => logger.info('Redis connected'))
redis.on('error', (err) => logger.error({ err }, 'Redis error'))
redis.on('reconnecting', (delay: number) => logger.warn({ delay }, 'Redis reconnecting'))
// Typed wrapper with automatic JSON serialization
export class RedisCache {
constructor(
private readonly client: Redis,
private readonly defaultTTL: number = 3600,
) {}
async get(key: string): Promise {
const raw = await this.client.get(key)
if (raw === null) return null
try {
return JSON.parse(raw) as T
} catch {
return raw as unknown as T
}
}
async set(key: string, value: T, ttlSeconds?: number): Promise {
const serialized = JSON.stringify(value)
const ttl = ttlSeconds ?? this.defaultTTL
await this.client.setex(key, ttl, serialized)
}
async del(...keys: string[]): Promise {
if (keys.length === 0) return 0
return this.client.del(...keys)
}
async exists(key: string): Promise {
const count = await this.client.exists(key)
return count > 0
}
async ttl(key: string): Promise {
return this.client.ttl(key)
}
}
export const cache = new RedisCache(redis)
Cache-Aside Pattern (Lazy Loading)
Cache-aside is the most common pattern: the application checks the cache first, fetches from the database on a miss, and writes the result back to the cache. The cache never holds data the application hasn’t explicitly put there.
// src/services/product-service.ts
import { prisma } from '../db/prisma'
import { cache } from '../cache/redis-client'
import type { Product } from '@prisma/client'
const PRODUCT_TTL = 600 // 10 minutes
const productKey = (id: string) => `product:v1:${id}`
export class ProductService {
async getById(id: string): Promise {
const key = productKey(id)
// 1. Check cache
const cached = await cache.get(key)
if (cached !== null) {
return cached
}
// 2. Cache miss — fetch from database
const product = await prisma.product.findUnique({ where: { id } })
// 3. Write to cache (only if found — don't cache null/404)
if (product !== null) {
await cache.set(key, product, PRODUCT_TTL)
}
return product
}
async update(id: string, data: Partial): Promise {
const updated = await prisma.product.update({ where: { id }, data })
// Invalidate cache after write
await cache.del(productKey(id))
return updated
}
async delete(id: string): Promise {
await prisma.product.delete({ where: { id } })
await cache.del(productKey(id))
}
}
// Generic cache-aside helper for any async function
export async function cacheAside(
key: string,
fetcher: () => Promise,
ttlSeconds: number = 600,
): Promise {
const cached = await cache.get(key)
if (cached !== null) return cached
const value = await fetcher()
if (value !== null) {
await cache.set(key, value, ttlSeconds)
}
return value
}
Comments · 0
No comments yet. Be the first to share your thoughts.