Cloud Tech by Victor
BackendIntermediate

Redis Caching Patterns

Cache-aside, write-through, and write-behind explained, plus the TTL, stampede, and invalidation pitfalls that show up once Redis caching hits real traffic.

Updated 2026-07-053 min read

Overview

Caching trades a small amount of staleness for a large amount of speed: instead of recomputing or re-fetching data on every request, a fast in-memory store like Redis serves recent results directly. The pattern you choose determines who is responsible for keeping the cache in sync with the source of truth, and how staleness shows up. Most systems default to cache-aside because it is simple and fails safe (a cache outage just means every request falls through to the database), reaching for write-through or write-behind only when read-after-write consistency or write-latency shaping specifically matters.

Quick Reference

PatternReadsWritesTrade-off
Cache-asideCheck cache, fall through to DB on miss, populate cacheDB onlySimple, cache can go stale between writes
Write-throughAlways from cacheCache + DB together, synchronouslyCache always fresh, extra write latency
Write-behindAlways from cacheCache immediately, DB asynchronouslyFastest writes, risk of data loss on crash before flush
Read-throughCache library itself fetches on missN/ASame as cache-aside, but the cache layer owns the fetch logic

Syntax

javascript
// Cache-aside with ioredis
async function getUser(id) {
  const cacheKey = `user:${id}`
  const cached = await redis.get(cacheKey)
  if (cached) return JSON.parse(cached)

  const user = await db.users.findById(id)
  await redis.set(cacheKey, JSON.stringify(user), 'EX', 300) // 5 minute TTL
  return user
}

Examples

javascript
// Stampede protection: a short-lived lock so only one request
// repopulates a hot key while others wait briefly.
async function getWithLock(key, fetchFn, ttlSeconds = 300) {
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached)

  const lockKey = `lock:${key}`
  const gotLock = await redis.set(lockKey, '1', 'NX', 'PX', 3000)

  if (!gotLock) {
    await new Promise((r) => setTimeout(r, 50))
    return getWithLock(key, fetchFn, ttlSeconds) // brief retry
  }

  try {
    const value = await fetchFn()
    await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds)
    return value
  } finally {
    await redis.del(lockKey)
  }
}
javascript
// Invalidate-after-write, correctly ordered: DB first, then cache.
async function updateUser(id, changes) {
  await db.users.update(id, changes)
  await redis.del(`user:${id}`)
}

Visual Diagram

Common Mistakes

  • No TTL at all, an unbounded cache key that never expires becomes a silent source of stale data with no automatic recovery path.
  • Invalidating the cache before writing the database, opening a window for a concurrent read to re-cache the old value.
  • Caching without a stampede guard on high-traffic keys, a single popular key expiring under load can spike database connections.
  • Using overly long TTLs to "reduce database load" without considering how stale the data is allowed to get for that specific use case.
  • Storing large objects in a single key instead of normalizing; this makes partial invalidation impossible and wastes memory on unused fields.

Performance

  • Redis operations are single-threaded per command but extremely fast (sub-millisecond) for simple key lookups; the bottleneck is almost always network round-trips, not Redis itself, so batching with pipelining or MGET matters more than micro-optimizing individual commands.
  • Jittered TTLs (baseTTL + random(0, jitter)) spread out expiration so many keys set at the same time do not all expire simultaneously and cause a coordinated stampede.
  • SCAN should always be used instead of KEYS in production, KEYS blocks the single-threaded server while it walks the entire keyspace.

Best Practices

  • Default to cache-aside; only reach for write-through/write-behind when you have a specific consistency or write-latency requirement that demands it.
  • Always set a TTL, even a long one, as a safety net against unbounded staleness.
  • Add stampede protection (locking or stale-while-revalidate) to any cache key that serves meaningfully hot traffic.
  • Namespace cache keys clearly (user:123, order:456:items) so invalidation and debugging stay tractable as the number of cached entities grows.

Interview questions

What is cache stampede and how do you prevent it?

A cache stampede happens when a popular cache key expires and many concurrent requests all miss at once, each falling through to hit the (often slow) origin, database or upstream API, simultaneously, sometimes overwhelming it. Common mitigations: a short-lived lock so only one request repopulates the cache while others wait or serve stale data (the "thundering herd" lock pattern), staggered/jittered TTLs so keys do not all expire at the same instant, and serving stale-while-revalidate, returning the expired value immediately while refreshing it in the background.

What is the difference between cache-aside and write-through caching?

Cache-aside (lazy loading): the application checks the cache first; on a miss it reads from the database, then writes the result into the cache. Writes go to the database only, so the cache can go stale until the next miss or an explicit invalidation. Write-through: every write goes to the cache and the database together (usually the cache write happens synchronously as part of the write path), so the cache is always in sync with the database, at the cost of extra write latency on every mutation.

Why can naive cache invalidation cause a race condition?

If a write invalidates (deletes) a cache key and then updates the database, a concurrent read between those two steps can repopulate the cache with the old value right after it was deleted, leaving stale data cached until the next TTL expiry or invalidation. Ordering matters: update the database first, then invalidate the cache, and even then, a very unlucky interleaving with a concurrent cache-aside read can still occur, which is why short TTLs are used as a safety net rather than relying on invalidation alone for strict consistency.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement