DEV Community

Anup Karanjkar
Anup Karanjkar

Posted on • Originally published at wowhow.cloud

Redis Caching Patterns for Node.js: Cache-Aside, Write-Through & More

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)),
    ])
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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)