Back to Blog
Web Development10 min readNovember 20, 2024

Redis Caching Patterns for Production Applications

Production Redis caching patterns: cache-aside, write-through, TTL strategies, cache invalidation, distributed locks, and avoiding common pitfalls like thundering herds and stale data.

RedisCachingBackendPerformanceNode.js
A

Azam

DevOps & AI Consultant

Caching Is Easy to Get Wrong

Adding a cache to a system is straightforward. Keeping that cache correct, handling failures gracefully, and avoiding the subtle failure modes that only emerge under load is significantly harder. This guide covers the production caching patterns that actually hold up, and the common mistakes that cause cache-related incidents.

Cache-Aside: The Standard Pattern

Cache-aside (lazy loading) is the most common pattern. The application checks the cache first, fetches from the database on a miss, and writes the result to the cache. The cache never contains data that hasn't been requested, which keeps memory usage efficient.

import { createClient } from 'redis'

const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()

async function getUserById(userId: string) {
  const cacheKey = `user:${userId}`

  // 1. Try cache
  const cached = await redis.get(cacheKey)
  if (cached) return JSON.parse(cached)

  // 2. Cache miss — fetch from DB
  const user = await db.users.findUnique({ where: { id: userId } })
  if (!user) return null

  // 3. Write to cache with TTL
  await redis.setEx(cacheKey, 300, JSON.stringify(user))

  return user
}

Always set a TTL. A cache without TTLs eventually fills up with stale data and evicts fresh data. For most application data, 5–15 minutes is a reasonable default. For data that rarely changes (user preferences, product categories), 1–24 hours is appropriate.

Cache Invalidation on Write

When data changes, invalidate the cache immediately rather than waiting for TTL expiry. Stale cache reads after a write feel like bugs to users.

async function updateUser(userId: string, data: Partial) {
  // Update database first
  const updated = await db.users.update({
    where: { id: userId },
    data,
  })

  // Invalidate cache — force next read to hit DB
  await redis.del(`user:${userId}`)

  // Or: write-through — update cache immediately
  await redis.setEx(`user:${userId}`, 300, JSON.stringify(updated))

  return updated
}

The choice between delete (invalidate) and update (write-through) depends on your read/write ratio. For read-heavy data, write-through avoids the cache miss after a write. For write-heavy data where the updated value may not be read again soon, deletion is more efficient.

The Thundering Herd Problem

When a cached item expires and many concurrent requests all miss the cache simultaneously, they all hit the database at once. For expensive queries, this can overwhelm the database. Solve it with a distributed lock — only one request rebuilds the cache; others wait briefly and then hit the now-warm cache.

async function getExpensiveData(key: string) {
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached)

  const lockKey = `lock:${key}`
  const lockAcquired = await redis.set(lockKey, '1', {
    NX: true,    // only set if not exists
    EX: 10,      // lock expires after 10s to prevent deadlock
  })

  if (!lockAcquired) {
    // Another process is rebuilding — wait briefly and retry
    await new Promise(r => setTimeout(r, 100))
    return getExpensiveData(key)  // recursive retry
  }

  try {
    const data = await expensiveDbQuery()
    await redis.setEx(key, 300, JSON.stringify(data))
    return data
  } finally {
    await redis.del(lockKey)  // always release the lock
  }
}

Distributed Locks for Critical Sections

Redis is the standard tool for distributed locks in Node.js applications. Use the Redlock algorithm for stronger guarantees across Redis cluster nodes.

import Redlock from 'redlock'

const redlock = new Redlock([redis], {
  retryCount: 3,
  retryDelay: 200,
})

async function processOrder(orderId: string) {
  const lockResource = `order:${orderId}:processing`

  await redlock.using([lockResource], 5000, async (signal) => {
    // Only one process executes this block at a time
    const order = await db.orders.findUnique({ where: { id: orderId } })
    if (order.status !== 'pending') return  // already processed

    await chargePayment(order)
    await db.orders.update({ where: { id: orderId }, data: { status: 'paid' } })

    if (signal.aborted) throw new Error('Lock expired during processing')
  })
}

Redis Data Structures Beyond String Caching

Most developers only use Redis for string key-value caching. The other data structures unlock powerful use cases:

  • Sorted Sets: Leaderboards, rate limiting with sliding windows, scheduling delayed jobs
  • Sets: Tracking unique visitors, user session IDs, feature flag recipients
  • Lists: Message queues, activity feeds, recent items
  • Hashes: User session data, shopping carts — more memory-efficient than separate string keys
// Rate limiting with sorted set (sliding window)
async function isRateLimited(userId: string, limit: number, windowMs: number) {
  const now = Date.now()
  const key = `ratelimit:${userId}`

  const pipe = redis.multi()
  pipe.zRemRangeByScore(key, 0, now - windowMs)  // remove old entries
  pipe.zAdd(key, { score: now, value: now.toString() })
  pipe.zCard(key)                                  // count requests in window
  pipe.expire(key, Math.ceil(windowMs / 1000))

  const results = await pipe.exec()
  const requestCount = results[2] as number
  return requestCount > limit
}

Production Checklist

  • Enable Redis persistence (AOF with appendfsync everysec) or accept that a restart loses your cache and database hits will spike
  • Set maxmemory and a sensible eviction policy (allkeys-lru for pure cache, volatile-lru if some keys should never be evicted)
  • Monitor keyspace_hits and keyspace_misses — a hit rate below 90% means your TTLs are too short or your access patterns don't benefit from caching
  • Use connection pooling — never create a new Redis connection per request
  • Implement circuit breakers: if Redis is unavailable, fall through to the database rather than failing the request entirely

Want to Build This for Your Team?

I help teams implement the patterns and architectures described in these articles. Let's talk about your project.

Book a Free Call