Wise Hustlers — Digital Product & App Development Studio Logo
Get Consultation
By Wise Hustler Admin9/18/20269 min read

Redis Caching Patterns That Actually Work in Production

Redis Caching Patterns That Actually Work in Production

# Redis Caching Patterns That Actually Work in Production

TL;DR: Cache-aside is the safest default, write-through keeps cache and database consistent at the cost of write latency, and TTLs need jitter plus a stampede guard (like probabilistic early expiration) or you'll recreate the exact "thundering herd" problem caching was supposed to fix — the failure mode in each pattern is almost always a race condition, not a Redis bug.

Caching with Redis looks trivial in a tutorial: GET, miss, SET, done. In production, the interesting part isn't the happy path — it's what happens when two requests race, a write fails halfway, or ten thousand keys expire in the same second. This post walks through the three patterns you'll actually use — cache-aside, write-through, and TTL-based expiration — with runnable code and the specific invalidation bugs each one is prone to.

Cache-Aside (Lazy Loading)

Cache-aside puts the application in charge of both reads and writes. On read, check Redis first; on miss, hit the database and populate the cache. On write, update the database and (usually) delete the cache key rather than update it.

import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);

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

  const user = await db.user.findUnique({ where: { id: userId } });
  if (user) {
    // NX avoids clobbering a fresher value another request just wrote
    await redis.set(cacheKey, JSON.stringify(user), 'EX', 300, 'NX');
  }
  return user;
}

async function updateUser(userId, data) {
  await db.user.update({ where: { id: userId }, data });
  await redis.del(`user:${userId}`); // invalidate, don't overwrite
}

The invalidation bug: the read-after-write race

Cache-aside's classic failure is this interleaving:

1. Request A reads user:42, misses the cache, and starts a DB query.

2. Request B updates the user's row and deletes user:42 from Redis.

3. Request A's stale DB read (started before B's write) finishes and writes the old value back into Redis.

4. Every subsequent read now serves stale data until the TTL expires.

This is exactly the "stale set" problem Facebook documented at scale in their Scaling Memcache at Facebook paper (NSDI 2013). Their fix was a lease mechanism: memcached hands out a token on a miss, and only the client holding the current lease token is allowed to complete the SET — late writes from an outdated read are rejected. Redis doesn't ship an equivalent primitive, but you can approximate it with a version counter or a short-lived lock key (SET lock:user:42 <token> PX 2000 NX) guarding the write-back, or simply accept a short staleness window if the data isn't strongly consistency-sensitive. Don't reach for SET without NX on cache repopulation if you've seen this bug — it's the one-line fix that stops the race from overwriting a newer delete.

Write-Through

Write-through flips the order: every write goes to the cache and the database together, synchronously, before the write is considered complete. Reads become simple — the cache is (in theory) always warm and always correct.

async function updateUserWriteThrough(userId, data) {
  const updated = await db.user.update({ where: { id: userId }, data });
  try {
    await redis.set(`user:${userId}`, JSON.stringify(updated), 'EX', 300);
  } catch (err) {
    // Cache write failed — log and let the next read repopulate from DB.
    // Do NOT fail the request over a cache miss.
    logger.error({ err, userId }, 'write-through cache set failed');
  }
  return updated;
}

The invalidation bug: partial-failure divergence

The risk here isn't a race — it's a partial failure. If the database write succeeds and the Redis write fails (network blip, Redis under memory pressure and evicting, a timeout), you now have a cache that's silently wrong until TTL expiry, and nothing in the request path told you that happened. The mirror case — writing to cache first, then the database call fails — is worse: the cache now holds data that was never actually persisted.

The practical guardrails:

  • Always write to the source of truth (the database) first, cache second. A failed cache write is recoverable (next read repopulates it); a failed DB write with a successful cache write is not.
  • Never let a cache write exception fail the user-facing request. Log it and move on — Redis being down should degrade you to "no cache," not "site down."
  • If you need the two writes to be atomic, that's usually a sign you actually want cache-aside with delete-on-write, not write-through — deletion is idempotent in a way that "write the new value" isn't when writes can reorder.

Write-through is worth the extra write latency for data that's read far more often than it's written and where a stale read is genuinely costly (pricing, entitlements, feature flags) — not for every entity in your schema.

TTL Strategies and the Stampede Problem

TTLs are the backstop for both patterns above — even with correct invalidation logic, you want an expiry as a safety net for bugs and manual data fixes you forgot to invalidate for. The naive version:

await redis.set(cacheKey, JSON.stringify(value), 'EX', 300);

The invalidation bug: synchronized expiry (thundering herd)

If you cache a batch of keys at the same time with the same TTL — a common pattern when warming a cache on deploy, or when a popular key gets refreshed by a cron job — they all expire simultaneously. The next request after expiry for each key misses, and if that key is hot, dozens or hundreds of concurrent requests all miss at once and hammer the database with the same expensive query. This is the "thundering herd" / "cache stampede" problem, and it's well documented — see the Wikipedia summary and the original analysis in Vattani, Chierichetti & Lowenstein's Optimal Probabilistic Cache Stampede Prevention (VLDB 2015).

Two fixes, both cheap to implement:

1. Jitter the TTL so keys don't expire in lockstep:

function ttlWithJitter(baseSeconds, jitterFraction = 0.1) {
  const jitter = baseSeconds * jitterFraction;
  return Math.floor(baseSeconds + (Math.random() * 2 - 1) * jitter);
}

await redis.set(cacheKey, JSON.stringify(value), 'EX', ttlWithJitter(300));

2. Probabilistic early expiration (XFetch), from the same paper: store how long the value took to compute alongside the value, and on each read, have the requester probabilistically decide to recompute before expiry — with probability rising as expiry approaches. One process ends up refreshing the value while everyone else still gets served the (still valid) cached copy:

function shouldRecompute(ttlRemainingMs, recomputeCostMs, beta = 1) {
  return Math.random() < Math.exp(-beta * recomputeCostMs / ttlRemainingMs) ? false
    : recomputeCostMs / ttlRemainingMs > Math.random();
}

(In practice, most teams get 90% of the benefit from jitter alone plus a Redis-based lock — SET stampede:user:42 1 PX 5000 NX — around the recompute so only one process refreshes a given key at a time while the rest serve the stale-but-present value.)

Push-Based Invalidation as an Alternative to TTL-Only

If your data changes unpredictably and staleness windows aren't acceptable, TTL alone isn't enough — you need to invalidate on write, actively. Redis supports this via keyspace notifications: enable them with CONFIG SET notify-keyspace-events Ex (or the relevant event classes), and subscribe to __keyevent@0__:expired or __keyevent@0__:set channels to react in real time — useful for invalidating a local in-process cache layer sitting in front of Redis. It's worth knowing the limitation going in: Redis pub/sub is fire-and-forget, so a disconnected subscriber silently misses events during the gap — treat it as a latency optimization on top of TTL, not a replacement for it.

Choosing a Pattern

PatternRead latencyWrite latencyConsistency riskBest for
Cache-asideFast after warmFast (delete only)Read-after-write raceGeneral-purpose, read-heavy data
Write-throughFast, always warmSlower (dual write)Partial-failure divergenceHot, write-light, consistency-sensitive data
TTL-onlyFast until expiryN/AStampede at expiryBackstop layered on the above, not standalone

Most production systems run cache-aside as the default with a TTL backstop, reserve write-through for a small set of hot/critical keys, and add jitter plus a recompute lock wherever a specific key is known to be hot enough to cause a stampede. If you're standing up this kind of caching layer as part of a broader infrastructure build — provisioning Redis itself, wiring in monitoring for eviction and memory pressure, and getting the deployment pipeline right — that's the kind of work covered under cloud & DevOps engineering.

FAQ

Should I use `redis.set` with `EX` or a separate `EXPIRE` call?

Use the atomic form (SET key value EX seconds) rather than a SET followed by a separate EXPIRE. Two calls create a window where the key exists without a TTL if the process crashes or is killed between them.

Does cache-aside or write-through perform better?

Cache-aside has lower write latency because writes only delete a key; write-through has more predictable read latency because the cache is never cold for keys that have been written. Pick based on your read:write ratio and how costly a cache miss is to recompute, not on raw throughput alone.

How long should a Redis TTL be?

There's no universal number — it depends on how expensive the underlying query is and how stale the data can safely be. A common approach is to set TTL based on how often the source data actually changes, then add 5–15% jitter so keys don't expire in a synchronized batch.

Is Redis Pub/Sub reliable enough for cache invalidation?

No, not on its own — Redis pub/sub delivers no message to a disconnected subscriber, so it should be layered on top of a TTL backstop, not used as the sole invalidation mechanism for anything where a missed invalidation is costly.

Sources

Related articles