Skip to main content
Engineering7 min readMarch 3, 2026

Redis Caching Strategies: When and How to Cache in Production

A practical guide to Redis caching — cache-aside vs write-through, TTL strategy, cache invalidation, session storage, and the common mistakes that make caches unreliable.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Caching is one of those tools that can make your system faster and more reliable, or introduce subtle bugs that are extremely hard to debug. The difference is understanding what you are actually caching, for how long, and what happens when the cached data becomes stale or wrong.

I have shipped systems where caching cut API response times from 400ms to 8ms. I have also inherited systems where caching bugs caused users to see each other's data. Here is how to get the former and avoid the latter.

When to Cache

Caching helps when:

  • The computation or database query is expensive and the result is used frequently
  • The data does not need to be perfectly fresh for every read
  • The access pattern is read-heavy with infrequent writes

Caching does not help (and adds complexity) when:

  • The data changes frequently enough that cached values are usually stale
  • The query is fast enough that cache overhead is significant in proportion
  • Correctness requires every read to see the latest data

A good rule: start without caching, measure, and add caching where you have profiled evidence that it helps. Premature caching is a source of bugs without corresponding benefits.

Connecting to Redis With ioredis

import Redis from 'ioredis'

const redis = new Redis({
  host: process.env.REDIS_HOST!,
  port: Number(process.env.REDIS_PORT ?? 6379),
  password: process.env.REDIS_PASSWORD,
  tls: process.env.NODE_ENV === 'production' ? {} : undefined,
  retryStrategy: (times) => {
    const delay = Math.min(times * 50, 2000)
    return delay
  },
  maxRetriesPerRequest: 3,
})

redis.on('error', (err) => {
  console.error('Redis connection error:', err)
})

export default redis

The retryStrategy ensures temporary Redis failures do not crash your application — it retries with exponential backoff. Design your application to degrade gracefully when Redis is unavailable.

Cache-Aside Pattern

The most common caching pattern. The application manages the cache explicitly:

async function getUser(userId: string): Promise<User> {
  const cacheKey = `user:${userId}`

  // Try cache first
  const cached = await redis.get(cacheKey)
  if (cached) {
    return JSON.parse(cached) as User
  }

  // Cache miss: fetch from database
  const user = await prisma.user.findUniqueOrThrow({
    where: { id: userId },
    select: {
      id: true,
      name: true,
      email: true,
      role: true,
      createdAt: true,
    },
  })

  // Store in cache with TTL
  await redis.setex(cacheKey, 300, JSON.stringify(user)) // 5 minutes

  return user
}

When the user is updated, invalidate the cache:

async function updateUser(userId: string, data: UpdateUserInput): Promise<User> {
  const user = await prisma.user.update({
    where: { id: userId },
    data,
  })

  // Invalidate the cached entry
  await redis.del(`user:${userId}`)

  return user
}

This is the simplest correct implementation. The weakness is that all cached user data expires when any field changes — even if the requesting component only needed one field that did not change.

Write-Through Cache

Write-through keeps the cache up to date by writing to both cache and database on every write:

async function updateUser(userId: string, data: UpdateUserInput): Promise<User> {
  const user = await prisma.user.update({
    where: { id: userId },
    data,
  })

  // Update cache with new data (not just invalidate)
  await redis.setex(`user:${userId}`, 300, JSON.stringify(user))

  return user
}

Write-through reduces cache miss rates at the cost of making writes slightly slower (two operations instead of one). For frequently-read, occasionally-written data (user profiles, configuration), this is a good trade.

TTL Strategy

Setting the right TTL (Time To Live) is critical. Too short: high database load, frequent cache misses. Too long: stale data, potential correctness issues.

My TTL guidelines:

  • User sessions: 24 hours or the session duration
  • User profile data: 5-15 minutes (changes rarely, reads frequently)
  • Product catalog: 1-24 hours (changes infrequently, high read volume)
  • Search results: 5-60 minutes (depends on data update frequency)
  • API rate limit counters: Duration of the rate limit window
  • Computed analytics: 1-24 hours

For data where staleness is acceptable but you want freshness when possible, use a short TTL with background refresh:

async function getWithBackgroundRefresh<T>(
  key: string,
  fetchFn: () => Promise<T>,
  ttl: number
): Promise<T> {
  const [cached, ttlRemaining] = await Promise.all([
    redis.get(key),
    redis.ttl(key),
  ])

  if (cached) {
    const data = JSON.parse(cached) as T

    // Refresh in background when TTL is below 20%
    if (ttlRemaining < ttl * 0.2) {
      fetchFn().then(fresh => redis.setex(key, ttl, JSON.stringify(fresh)))
    }

    return data
  }

  const fresh = await fetchFn()
  await redis.setex(key, ttl, JSON.stringify(fresh))
  return fresh
}

Cache Invalidation Patterns

"There are only two hard things in Computer Science: cache invalidation and naming things."

The hard part of cache invalidation is knowing which cached entries to invalidate when data changes. Simple cases are easy: update user 42, delete user:42. Complex cases are not:

// When a post is published:
// - Invalidate the post itself
// - Invalidate the author's post count
// - Invalidate the category listing
// - Invalidate the homepage featured posts
// - Invalidate search indexes

For complex invalidation, use cache tags. Group related cache entries under a tag and invalidate the entire group:

async function cacheWithTags(
  key: string,
  tags: string[],
  value: unknown,
  ttl: number
) {
  const pipeline = redis.pipeline()

  pipeline.setex(key, ttl, JSON.stringify(value))

  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, key)
    pipeline.expire(`tag:${tag}`, ttl * 2)
  }

  await pipeline.exec()
}

async function invalidateTag(tag: string) {
  const keys = await redis.smembers(`tag:${tag}`)
  if (keys.length > 0) {
    await redis.del(...keys, `tag:${tag}`)
  }
}

// Usage
await cacheWithTags(
  `post:${postId}`,
  ['posts', `user:${userId}:posts`, 'featured'],
  postData,
  3600
)

// When a post is updated, invalidate all related caches
await invalidateTag(`user:${userId}:posts`)

Session Storage

Redis is the standard choice for distributed session storage:

// With better-auth
export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: 'postgresql' }),
  session: {
    sessionStore: redisSessionStore({
      client: redis,
      prefix: 'session:',
      ttl: 60 * 60 * 24 * 7, // 7 days
    }),
  },
})

Sessions in Redis rather than PostgreSQL means:

  • Session reads are sub-millisecond vs 5-10ms for database reads
  • Session storage scales independently of your main database
  • Session invalidation (logout) is instant

Rate Limiting With Redis

Redis's atomic increment operations are perfect for rate limiting:

async function rateLimit(
  identifier: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
  const key = `rate:${identifier}:${Math.floor(Date.now() / (windowSeconds * 1000))}`

  const current = await redis.incr(key)

  if (current === 1) {
    await redis.expire(key, windowSeconds)
  }

  const ttl = await redis.ttl(key)
  const resetAt = Date.now() + ttl * 1000

  return {
    allowed: current <= limit,
    remaining: Math.max(0, limit - current),
    resetAt,
  }
}

The sliding window counter pattern is more accurate but more complex. For most rate limiting use cases, this fixed window approach is sufficient.

Avoiding Common Mistakes

Never cache sensitive data without encryption. Redis is a key-value store, not a secure vault. Cache tokens, session IDs (not session data with PII), and computed values. If you must cache sensitive data, encrypt it.

Handle Redis errors gracefully. Redis unavailability should degrade your application, not crash it:

async function getWithFallback<T>(key: string, fallback: () => Promise<T>): Promise<T> {
  try {
    const cached = await redis.get(key)
    if (cached) return JSON.parse(cached)
  } catch (err) {
    console.error('Redis error, falling back to database:', err)
  }

  return fallback()
}

Use pipelines for multiple operations. Multiple Redis commands in a single round trip:

const pipeline = redis.pipeline()
pipeline.get('key1')
pipeline.get('key2')
pipeline.incr('counter')
const results = await pipeline.exec()

Caching is powerful when applied deliberately. Know what you are caching, why, for how long, and how you will handle stale data. The complexity is worth it when you have the measurements to justify it.


Designing a caching strategy for a high-traffic application or dealing with Redis configuration issues? Book a call and let's work through it: calendly.com/jamesrossjr.


Keep Reading