Three Redis Caching Patterns I Actually Use
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.