IAnswerable

Three Redis Caching Patterns That Cover Most Cases

29 Jan 2026

Most apps only need one of three caching patterns. Pick the wrong one and you either serve stale data or you lose the whole point of caching under load. Here’s what to use when.

Cache-aside (lazy loading)

The default choice. The app checks the cache first; on a miss, it reads from the database, then writes the result into the cache.

async function getUser(id) {
  const cached = await redis.get(`user:${id}`)
  if (cached) return JSON.parse(cached)

  const user = await db.query('SELECT * FROM users WHERE id = $1', [id])
  await redis.setex(`user:${id}`, 300, JSON.stringify(user))
  return user
}

Use it for: read-heavy data that tolerates a few minutes of staleness — user profiles, product listings, config.

Failure mode: cache stampede. If user:42 expires and gets 500 concurrent requests in the same second, all 500 miss the cache and all 500 hit the database at once. Fix with a short lock or a “stale-while-revalidate” approach: serve the expired value while one request refreshes it in the background.

Write-through

The app writes to the cache and the database together, in the write path, instead of waiting for the next read to populate the cache.

async function updateUser(id, data) {
  await db.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, id])
  await redis.setex(`user:${id}`, 300, JSON.stringify(data))
}

Use it for: data you read immediately after writing — a user updating their own profile and expecting to see the change on the next page load. Cache-aside alone can serve a stale read right after a write if the timing lines up wrong.

Failure mode: if the database write succeeds and the cache write fails (or vice versa), they diverge. Wrap both in a pattern where the cache write is best-effort and you set a short TTL as backstop — if it goes stale, it self-heals in minutes instead of staying wrong forever.

TTL with jitter

Both patterns above need a TTL. The mistake is giving every key in a batch the exact same TTL.

// Bad: every key in this batch expires in the same second
await redis.setex(key, 300, value)

// Better: spread expirations over a window
const jitter = Math.floor(Math.random() * 30)
await redis.setex(key, 300 + jitter, value)

If you warm 10,000 cache keys at deploy time with a flat 5-minute TTL, they all expire in the same second five minutes later, and you get a thundering herd of database queries at that exact moment. Jitter spreads the expirations across a window so the load smooths out instead of spiking.

Picking one

Pattern Read freshness Write cost Best for
Cache-aside Can be stale until next miss Cheap (no extra write) General read-heavy data
Write-through Fresh immediately Extra write on every mutation Data read right after writing
TTL + jitter N/A — modifier N/A Always add this to whichever pattern you pick

Start with cache-aside plus jitter. It’s the cheapest to implement and covers most cases. Add write-through only for the specific keys where users notice staleness — don’t apply it blanket, or every write in your app now does two round trips instead of one.

The one thing that breaks all of them

Forgetting to invalidate on delete. If a row gets deleted from the database but nobody calls redis.del() on its key, the cache serves a ghost record until the TTL runs out. Every delete path in your app needs an explicit cache invalidation, not just every update path — it’s the one people forget because deletes are rarer than updates.