Redis Caching Strategies: Cache-Aside, Write-Through, and Cache Invalidation Explained

By Yash Amin, Founder & CEO•August 4, 2026•10 min read
Redis caching architecture showing cache-aside, write-through, and invalidation strategies

The safe default is cache-aside: check the cache, fall back to the database on a miss, and let Redis stay a pure performance layer you can lose without breaking anything. Write-through and write-behind trade that safety for stronger consistency guarantees, at a real cost in complexity.

Adding Redis in front of a slow database is often the first thing teams reach for when performance complaints start. It works — until it doesn't.

' It's stale data served to users after an update, a cache stampede that takes the database down harder than if the cache never existed, or a caching layer nobody trusts anymore because nobody can explain when it invalidates. Caching is not a bolt-on.

It's an architectural decision with real tradeoffs, and getting the pattern wrong causes more incidents than not caching at all. This guide covers the patterns that hold up under real production load: cache-aside as the default, write-through and write-behind for when staleness isn't acceptable, invalidation strategies that don't rot silently, and the stampede problem that catches almost every team the first time a popular key expires under load.

Want a second opinion on your caching architecture? Book a 30-min diagnostic →

Cache-Aside (Lazy Loading): The Default Pattern

Cache-aside is the pattern most teams should reach for first. The application checks the cache; on a miss, it reads from the database, writes the result into the cache with a TTL, and returns it. On a hit, it skips the database entirely.

The appeal is that Redis stays a pure performance layer — if it goes down, the application falls back to hitting the database directly (slower, but correct) instead of breaking. The tradeoff is that the first request after a cache miss always pays the full database latency, and data can be stale for up to the TTL window after an underlying update. For read-heavy data that tolerates a few seconds to minutes of staleness — product listings, user profiles, dashboard aggregates — cache-aside with a sensible TTL is almost always the right starting point before reaching for anything more complex.

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

  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  await redis.set(cacheKey, JSON.stringify(user), 'EX', 300); // 5 min TTL
  return user;
}

Related: how missing caching shows up as database waste, PostgreSQL 18's I/O monitoring improvements

Write-Through and Write-Behind: When Staleness Isn't Acceptable

Write-through updates the cache and the database in the same operation — every write goes to Redis first (or in parallel), then to the database, so the cache is never stale after a write completes. This costs extra write latency but eliminates the read-after-write staleness window that cache-aside allows. Write-behind (write-back) goes further: writes land in Redis immediately and are flushed to the database asynchronously in batches, which lowers write latency dramatically but introduces real risk — if Redis crashes before the flush, that data is gone unless it's also persisted (AOF or RDB) or queued somewhere durable.

Write-behind is the right call for high-frequency, loss-tolerant writes like view counters or activity logs; it's the wrong call for anything resembling a financial transaction or order state, where write-through — or no caching on the write path at all — is the safer default.

// Write-through: cache and DB updated together, cache never stale
async function updateUserEmail(userId, email) {
  await db.query('UPDATE users SET email = $1 WHERE id = $2', [email, userId]);
  await redis.set(`user:${userId}`, JSON.stringify({ userId, email }), 'EX', 300);
}

Cache Invalidation: The Actually Hard Part

TTL-based expiration is simple and self-healing — worst case, data is stale for the TTL window, then it corrects itself on the next miss. It's the right default for most data. But some data needs to be correct immediately after a write, not eventually: a user changes their plan tier, and the next request must see the new tier, not the cached one for up to five more minutes.

For that, explicit invalidation on write — deleting or updating the specific cache key the moment the underlying data changes — is required, not optional. The failure mode teams hit here is invalidating the wrong key, or forgetting to invalidate a derived key (a cached list that includes the object you just updated, not just the object's own key). Keep an explicit map of which writes must invalidate which cache keys, including derived and list-level keys, and treat it as part of the data model, not an afterthought bolted onto the update function.

async function updateUserPlan(userId, plan) {
  await db.query('UPDATE users SET plan = $1 WHERE id = $2', [plan, userId]);
  // Invalidate the direct key AND any derived keys that embed this user's data
  await redis.del(`user:${userId}`);
  await redis.del(`user:${userId}:billing-summary`);
}

Still Piecing This Together Yourself?

A senior engineer looks at your actual setup, not a generic checklist, and tells you exactly what's wrong and how to fix it.

Book a Diagnostic Call

The Thundering Herd Problem (Cache Stampede)

This is the incident almost every team eventually has. A hot key — say, a homepage feed or a popular product page — expires. In the same instant, hundreds or thousands of concurrent requests all get a cache miss, and all of them hit the database simultaneously to regenerate the same value.

A database that handled the cached load fine gets hit with the full uncached load all at once, and can fall over from a single key's expiration. Two fixes handle this reliably. First, a lock (or 'single-flight' pattern): the first request to miss acquires a short-lived lock and regenerates the value while every other concurrent request either waits briefly or serves the stale value until the lock releases.

Second, probabilistic early expiration: recompute the value slightly before the TTL actually expires, with a random jitter per request, so regeneration is spread out instead of synchronized to the exact expiration millisecond.

async function getWithStampedeProtection(key, fetchFn, ttl = 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', 'EX', 10);
  if (!gotLock) {
    // Someone else is already regenerating — brief wait, then retry cache
    await new Promise((r) => setTimeout(r, 50));
    return getWithStampedeProtection(key, fetchFn, ttl);
  }

  const value = await fetchFn();
  await redis.set(key, JSON.stringify(value), 'EX', ttl);
  await redis.del(lockKey);
  return value;
}

Related: our database engineering service

Designing Cache Keys That Don't Bite You Later

Cache key design looks trivial until a schema change or a feature flag makes half your cached data structurally wrong in a way nothing detects. Version your keys — user:v2:123 instead of user:123 — so a shape change to the cached payload can't silently return old-shaped data to code expecting the new shape; bump the version and old keys simply expire unread. Namespace keys by tenant in multi-tenant systems (tenant:42:user:123) to make it structurally impossible for one tenant's cached data to leak into another's request.

, while user:123:profile tells them everything they need to check it manually with redis-cli.

Get a Straight Answer, Not a Sales Pitch

Tell us what you're running into. We'll tell you directly what's causing it and what it takes to fix, before you sign anything.

Contact Us

When Redis Caching Is the Wrong Fix

Caching is frequently used to paper over a query that should be fixed instead. If a dashboard is slow because of an unindexed full table scan, caching the result hides the symptom without addressing the fact that the underlying query will time out the moment cache is cold, memory pressure evicts the key, or a new code path queries the same data without going through the cache. Fix the query first; cache what's still expensive after that.

Caching is also the wrong call when consistency requirements are genuinely strict — inventory counts during checkout, account balances, anything where a stale read causes a real business error rather than a cosmetic delay. In those cases, the correct fix is usually a faster, correctly indexed read from the source of truth, not a cache with a short enough TTL to feel safe.

Related: how to debug slow SQL queries

Redis caching earns its complexity when it's applied deliberately: cache-aside as the default for read-heavy, staleness-tolerant data; write-through when correctness after a write matters more than write latency; explicit invalidation wired into the data model wherever TTL alone isn't fast enough; and stampede protection on any key popular enough that its expiration could generate a load spike. Most caching incidents trace back to skipping one of these — a key that should have been invalidated wasn't, or a hot key expired without a lock and took the database down with it. Get the pattern right before you scale the cache, because a caching layer nobody trusts gets ripped out, and the team is back to the slow database it started with — usually during an incident, at the worst possible time to be making that call.

Frequently Asked Questions

Cache-aside or write-through — which should I use by default?

Cache-aside, unless a specific field genuinely needs to be correct immediately after every write. Cache-aside is simpler, self-healing on failure, and tolerates a Redis outage gracefully by falling back to the database. Reserve write-through for data where a stale read in the seconds after a write causes a real problem, like a permissions change or a plan tier upgrade.

What TTL should I set for a Redis cache?

Start with how stale the data can be before it causes a problem, not an arbitrary round number. Product listings might tolerate 5-15 minutes; a user session might need seconds; a rarely-changing configuration value might safely cache for hours. Set the TTL from the business tolerance for staleness, then adjust based on hit rate and database load observed in production.

How do I prevent a Redis cache stampede?

Use a short-lived lock so only one request regenerates a value on cache miss while others wait briefly or serve stale data, or use probabilistic early expiration with random jitter so hot keys don't all expire and regenerate at the exact same millisecond under concurrent load.

Should I cache database query results or computed application data?

Both, for different reasons. Caching raw query results reduces database load; caching computed/aggregated application data (a rendered dashboard summary, a formatted API response) additionally saves the CPU cost of recomputation. The higher the computation cost relative to the query cost, the more valuable it is to cache the computed result rather than just the raw rows.

Get a Straight Answer on Your Setup

Tell us what you're running into. We read every message personally and reply within 24 hours with times for a free call.

We'll reply within 24 hours. Your information is never shared.