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.
Try it yourself: Free .env File Converter — free, no signup, runs in your browser.
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
}
Write-Through Pattern
Write-through updates the cache synchronously on every write, so the cache always reflects the database. Best for read-heavy data that must never be stale. The cost is write latency (two writes per update).
// src/services/user-session-service.ts — write-through for sessions
import { cache } from '../cache/redis-client'
import { prisma } from '../db/prisma'
import type { UserSession } from '@prisma/client'
const SESSION_TTL = 86400 * 7 // 7 days
const sessionKey = (token: string) => `session:v1:${token}`
export class UserSessionService {
async create(userId: string, token: string, metadata: object): Promise {
const expiresAt = new Date(Date.now() + SESSION_TTL * 1000)
// Write to DB and cache together
const [session] = await Promise.all([
prisma.userSession.create({
data: { userId, token, metadata, expiresAt },
}),
cache.set(sessionKey(token), { userId, metadata, expiresAt }, SESSION_TTL),
])
return session
}
async getByToken(token: string): Promise {
// Try cache first (will almost always hit for active sessions)
const cached = await cache.get(sessionKey(token))
if (cached) return cached
// Fallback: DB + re-populate cache
const session = await prisma.userSession.findUnique({ where: { token } })
if (session && session.expiresAt > new Date()) {
const remainingTTL = Math.floor((session.expiresAt.getTime() - Date.now()) / 1000)
await cache.set(sessionKey(token), session, remainingTTL)
}
return session
}
async revoke(token: string): Promise {
await Promise.all([
prisma.userSession.delete({ where: { token } }),
cache.del(sessionKey(token)),
])
}
}
Write-Behind (Write-Back) Pattern
Write-behind writes to the cache immediately and defers the database write. This dramatically improves write throughput for high-frequency updates like view counts, likes, or analytics events. The trade-off is data loss risk on crash before the flush.
// src/services/view-counter-service.ts — write-behind for analytics
import { redis } from '../cache/redis-client'
import { prisma } from '../db/prisma'
const viewCountKey = (postId: string) => `views:pending:${postId}`
const FLUSH_INTERVAL_MS = 30_000 // Flush every 30 seconds
const FLUSH_BATCH_SIZE = 100
export class ViewCounterService {
// Increment in Redis only — fast, no DB hit
async increment(postId: string): Promise {
await redis.incr(viewCountKey(postId))
}
async get(postId: string): Promise {
const pending = await redis.get(viewCountKey(postId))
const dbCount = await prisma.post.findUnique({
where: { id: postId },
select: { viewCount: true },
})
return (dbCount?.viewCount ?? 0) + Number(pending ?? 0)
}
// Run this on a schedule (e.g., every 30 seconds via setInterval or cron)
async flushToDatabase(): Promise {
const keys = await redis.keys('views:pending:*')
if (keys.length === 0) return
// Process in batches
for (let i = 0; i pipeline.getdel(key))
const results = await pipeline.exec()
const updates = batch
.map((key, idx) => ({
postId: key.replace('views:pending:', ''),
count: Number(results?.[idx]?.[1] ?? 0),
}))
.filter((u) => u.count > 0)
await Promise.all(
updates.map(({ postId, count }) =>
prisma.post.update({
where: { id: postId },
data: { viewCount: { increment: count } },
}),
),
)
}
}
}
// Start the flush interval
const viewCounter = new ViewCounterService()
setInterval(() => viewCounter.flushToDatabase().catch(console.error), FLUSH_INTERVAL_MS)
Cache Invalidation: Tag-Based Strategy
Tag-based invalidation groups related cache keys under a tag, so you can invalidate all of them atomically. This solves the problem of "product updated — which cache keys hold product data?"
// src/cache/tag-invalidation.ts
import { redis } from './redis-client'
const tagSetKey = (tag: string) => `cache-tag:${tag}`
export class TaggedCache {
async set(
key: string,
value: T,
options: { ttl?: number; tags?: string[] } = {},
): Promise {
const { ttl = 600, tags = [] } = options
const pipeline = redis.pipeline()
pipeline.setex(key, ttl, JSON.stringify(value))
// Register this key under each tag
for (const tag of tags) {
pipeline.sadd(tagSetKey(tag), key)
pipeline.expire(tagSetKey(tag), ttl + 60) // Tag outlives the cached values
}
await pipeline.exec()
}
async invalidateTag(tag: string): Promise {
const tagKey = tagSetKey(tag)
const keys = await redis.smembers(tagKey)
if (keys.length === 0) return 0
const pipeline = redis.pipeline()
keys.forEach((key) => pipeline.del(key))
pipeline.del(tagKey)
await pipeline.exec()
return keys.length
}
}
// Usage:
const taggedCache = new TaggedCache()
// Cache product with tags
await taggedCache.set(`product:${product.id}`, product, {
ttl: 600,
tags: [`product:${product.id}`, `category:${product.categoryId}`, 'products'],
})
// Invalidate everything in a category
await taggedCache.invalidateTag(`category:${categoryId}`)
// All product caches, listing caches, etc. for that category are gone
Pub/Sub for Cache Synchronization Across Instances
// src/cache/cache-invalidation-bus.ts
// When running multiple app instances, one instance updating DB must
// tell all other instances to drop their local in-memory caches.
import Redis from 'ioredis'
const INVALIDATION_CHANNEL = 'cache:invalidation'
// Separate connection for pub/sub (subscriber blocks the connection)
const subscriber = new Redis({
host: process.env.REDIS_HOST,
password: process.env.REDIS_PASSWORD,
})
const publisher = new Redis({
host: process.env.REDIS_HOST,
password: process.env.REDIS_PASSWORD,
})
type InvalidationMessage = {
type: 'key' | 'tag' | 'pattern'
value: string
instanceId: string
}
const LOCAL_INSTANCE_ID = process.env.HOSTNAME ?? Math.random().toString(36).slice(2)
const listeners = new Set void>()
subscriber.subscribe(INVALIDATION_CHANNEL, (err) => {
if (err) console.error('Cache invalidation subscribe error:', err)
})
subscriber.on('message', (_channel: string, message: string) => {
try {
const msg = JSON.parse(message) as InvalidationMessage
if (msg.instanceId !== LOCAL_INSTANCE_ID) {
listeners.forEach((listener) => listener(msg))
}
} catch {}
})
export function onCacheInvalidation(listener: (msg: InvalidationMessage) => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export async function publishCacheInvalidation(
type: InvalidationMessage['type'],
value: string,
): Promise {
const msg: InvalidationMessage = { type, value, instanceId: LOCAL_INSTANCE_ID }
await publisher.publish(INVALIDATION_CHANNEL, JSON.stringify(msg))
}
People Also Ask
What is the difference between cache-aside and write-through in Redis?
Cache-aside (lazy loading) only populates the cache on reads — the application fetches from DB on a miss and writes to cache. Write-through populates the cache on every write, keeping it always current. Cache-aside risks a stampede on cold cache start; write-through has higher write latency but zero cold-start staleness.
How do I prevent Redis cache stampede (thundering herd)?
When a popular key expires, many concurrent requests all miss the cache simultaneously and hammer the database. Solutions: (1) Use SET NX EX to implement a lock — only one request recomputes the value while others wait. (2) Add jitter to TTLs so keys don't expire simultaneously. (3) Use probabilistic early expiration — stochastically refresh keys before they actually expire.
What TTL should I use for Redis caching?
It depends on how often data changes and how stale you can tolerate. User sessions: match auth token lifetime (hours/days). Product catalog: 5-15 minutes with tag invalidation on writes. Search results: 30-60 seconds. Leaderboards/counts: 10-30 seconds. Never use TTL=0 (never expires) in production without a deliberate invalidation strategy — Redis will fill to maxmemory and start evicting randomly.
Redis implementation templates, Node.js caching boilerplates, and production starter kits are at WOWHOW developer tools. See the full catalog at wowhow.cloud/browse.
Originally published at wowhow.cloud
Top comments (0)