Caching Strategies That Don't Lie: Invalidation in Practice#
Adding a cache is the fastest way to make a slow endpoint fast and the fastest way to show users wrong data. Phil Karlton's famous line about cache invalidation being one of the two hard problems in computer science is funny because every senior engineer has been burned by it.
This post is about designing caches that stay correct, not just fast.
Start with the question: how stale is acceptable?#
Before choosing a strategy, agree with the product owner on the staleness budget for each piece of data:
- A blog post list can be minutes old without anyone noticing.
- A product price can be seconds old, but must be exact at checkout.
- An account balance should never come from a cache that can lag behind writes.
The answer decides the strategy. Caching without this conversation is how you end up with support tickets about "the wrong number on the dashboard".
Cache-aside: the default#
The application checks the cache, falls back to the database on a miss, and fills the cache:
1async function getProject(id: string): Promise<Project> {2 const key = `project:${id}:v1`;3 const cached = await redis.get(key);4 if (cached) return JSON.parse(cached);5 6 const project = await db.project.findUniqueOrThrow({ where: { id } });7 await redis.set(key, JSON.stringify(project), "EX", 300);8 return project;9}
It is simple, it degrades gracefully when the cache is down, and the cache only holds data someone actually asked for. On writes, delete the key rather than updating it:
1await db.project.update({ where: { id }, data });2await redis.del(`project:${id}:v1`);
Deleting is safer than writing the new value, because two concurrent writers updating the cache can leave it holding the older value. A delete just forces the next reader to load fresh data.
The race that still bites cache-aside#
Even with delete-on-write there is a window:
- Reader misses the cache and loads the old row.
- Writer updates the row and deletes the key.
- Reader writes the old row into the cache.
Now the cache holds stale data until the TTL expires. Two mitigations:
- Always set a TTL. It bounds how long any inconsistency can survive.
- Delayed double delete. Delete on write, then delete again a second or two later to clear anything a slow reader put back.
For most read-heavy data, a short TTL alone is enough.
Cache stampedes#
When a popular key expires, hundreds of requests miss at once and all hit the database together. I have seen this take down a database that handled normal load with ease.
Three defenses, usually combined:
- Request coalescing: only one request recomputes the value while the others wait for it.
- Jittered TTLs: add randomness so keys created together do not expire together.
- Stale-while-revalidate: serve the old value briefly while one worker refreshes it in the background.
1const ttl = 300 + Math.floor(Math.random() * 60); // 5-6 minutes
Versioned keys beat clever invalidation#
Some data is hard to invalidate key by key — for example, every list page that contains a given project. Instead of tracking every dependent key, put a version in the key and bump it on change:
1const version = await redis.get("projects:version") ?? "1";2const key = `projects:list:${version}:page:${page}`;3 4// on any project write5await redis.incr("projects:version");
Old keys are never read again and simply expire. You trade some memory for invalidation logic you can actually reason about.
Layers of caching#
A production system usually has several caches, and each one needs its own invalidation story:
- CDN / edge for public pages and assets, invalidated by path or by short
s-maxage. - Application cache (Redis) for computed or joined data.
- In-process memory for tiny, very hot, rarely changing data such as feature flags.
- Database buffer cache, which you get for free.
The more layers, the more places stale data can hide. Add a layer only when measurements show you need it.
Observability for caches#
A cache you cannot measure is a cache you cannot trust. Track at minimum:
- Hit rate per key prefix — a low hit rate means the cache is just adding latency.
- Memory usage and eviction count — evictions mean your working set does not fit.
- Latency of the miss path — this is your real worst case.
Conclusion#
Correct caching comes down to a few habits: agree on a staleness budget, delete instead of update, always set a TTL, protect against stampedes, and prefer versioned keys over clever invalidation graphs. Fast is easy. Fast and correct takes design.