Caching Strategies and the Invalidation Trap
Cache-aside, write-through, and write-behind — what each one guarantees, where they break, and how to think about the hardest part: invalidation.
The Problem
A read-heavy endpoint hammers your database with the same query thousands of times a second. The obvious fix is a cache. The non-obvious part is everything that comes after: stale data, thundering herds, and the question that has haunted engineers forever — when do you invalidate?
Why It Matters
Caching is the highest-leverage performance tool you have, but it trades freshness for speed. Get the strategy wrong and you serve stale prices, leak one user's data to another, or melt your database the moment a popular key expires.
Core Concepts
Three patterns cover most needs:
- Cache-aside (lazy): the app checks the cache, and on a miss loads from the database and populates it. Simple, and the default for most systems.
- Write-through: writes go to the cache and the database together, so the cache is always warm and consistent — at the cost of slower writes.
- Write-behind: writes hit the cache and are flushed to the database asynchronously. Fast writes, but a crash can lose buffered data.
Implementation
Cache-aside is the workhorse:
async function getUser(id: string): Promise<User> {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const user = await db.users.findUnique({ where: { id } });
// Short TTL bounds staleness even if an invalidation is missed.
await redis.set(key, JSON.stringify(user), "EX", 300);
return user;
}
async function updateUser(id: string, data: Partial<User>) {
const user = await db.users.update({ where: { id }, data });
await redis.del(`user:${id}`); // invalidate, don't try to update in place
}
Deleting on write is safer than rewriting the cache: the next read repopulates from the source of truth, so you can't cache a half-applied update.
Common Mistakes
- No TTL. Without expiry, a single missed invalidation means data that's stale forever. A TTL is your safety net.
- Caching per-user data under a shared key. The fastest way to leak one user's data to another. Always namespace by identity.
- Updating the cache in place on writes. Race conditions can leave the cache newer-but-wrong. Delete and let the next read rebuild it.
Production Considerations
Guard against cache stampedes: when a hot key expires, thousands of concurrent misses all hit the database at once. Mitigate with a short lock so one request recomputes while others wait, or by refreshing slightly before expiry.
const lock = await redis.set(`lock:${key}`, "1", "NX", "EX", 5);
if (!lock) return await waitForFreshValue(key); // someone else is recomputing
Security
Never let a cache key be derived from unsanitized user input in a way that crosses tenant boundaries. Treat cached responses as carrying the same authorization context as the original read.
Performance
The win is dramatic — sub-millisecond reads instead of database round-trips — but measure hit rate. A cache below ~80% hit rate often adds latency and complexity for little gain. Size it so the working set actually fits.
Summary
Pick cache-aside unless you have a specific reason not to. Always set a TTL, delete rather than rewrite on updates, namespace keys by identity, and plan for stampedes on hot keys. Caching is easy to add and hard to get exactly right — the TTL is what saves you when invalidation inevitably slips.
The weekly engineering digest
Production-grade engineering writing in your inbox. No spam, unsubscribe anytime.