Skip to content
// booting portfolio
the engineer's notebookYash.
loading assets000%
Yash.
All writing
RedisBackendCachingNode.js

Three Redis Caching Patterns I Actually Use

6 min read

Caching is one of those things that looks trivial until you have a stale value in production and can't figure out why. After using Redis across a few projects, I've settled on three patterns that cover almost everything I run into. Here's when each fits and the mistakes that taught me to use them.

1. Cache-aside (the default)

The application checks the cache first; on a miss, it reads the source, stores the result, and returns it. This is the one you reach for 80% of the time.

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

  const user = await db.user.findById(id).lean();
  if (user) await redis.set(key, JSON.stringify(user), 'EX', 3600);
  return user;
}

The gotcha: caching the miss. If a lookup returns nothing and you don't handle it, every request for a non-existent key hammers the database — a cache penetration. For hot keys, cache a short-lived sentinel for misses too.

2. Write-through (when reads must be fresh)

With cache-aside, the cache only updates on the next read. If you can't tolerate that gap, write to the cache and the database together:

async function updateUser(id: string, patch: Partial<User>) {
  const user = await db.user.findByIdAndUpdate(id, patch, { new: true }).lean();
  await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
  return user;
}

The read path is now guaranteed fresh. The cost is slightly slower writes and the risk of the two stores drifting if the cache write fails — so treat the DB as the source of truth and let the cache rebuild on a miss if anything goes wrong.

3. Tag-based invalidation (for related data)

The hard problem isn't caching one record — it's invalidating everything that depends on it. When a post changes, the post page, the homepage list, and the tag pages all go stale. Maintaining a set of keys per tag lets you nuke them together:

async function tagKey(tag: string, key: string) {
  await redis.sadd(`tag:${tag}`, key);
}

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

Now invalidateTag('posts') clears every cached view that registered under it. This is the pattern that finally made my CMS feel instant and never stale.

The mistakes worth naming

  • No TTL. Every key should expire eventually, even cache-aside ones. A cache with no TTL is a memory leak with extra steps.
  • The thundering herd. When a popular key expires, a hundred requests all miss at once and stampede the DB. A short lock around the recompute, or staggered TTLs, smooths it out.
  • Caching before measuring. I've cached things that were never the bottleneck. Profile first; the slow query is rarely where you assumed.

None of these patterns are clever. That's the point — caching bugs come from reaching for cleverness when one of these three boring patterns was the right answer all along.

Comments (0)

  • Be the first to comment.