Cache Invalidation, In Practice
The joke says it's one of the two hard problems. The reality is that most teams never write an invalidation strategy at all — they write a TTL and hope. Here is what the alternatives actually cost.

There is a well-worn joke about the two hard things in computer science being cache invalidation and naming things. Like most engineering jokes it survives because it is half true and completely useless as guidance.
What I've found is that cache invalidation isn't hard in the way the joke implies. It is hard because teams skip the design step entirely. Someone adds a cache to fix a slow endpoint, picks a TTL that feels reasonable, and ships. Nobody writes down what staleness is acceptable, so nobody notices when it stops being acceptable. Six months later a support ticket says a price changed yesterday and the app still shows the old one.
The cache wasn't wrong. It was doing exactly what it was told. Nobody had decided what to tell it.
Start with the question nobody asks
Before choosing Redis, TTLs, or invalidation events, answer this: how stale can this data be before someone is harmed?
Not "before someone notices." Harmed. The two are very different numbers.
- A product catalogue on a marketing page: hours are fine. Nobody is hurt by a description that's a day old.
- A price shown at checkout: seconds, and arguably never — the price you charge must equal the price you displayed.
- A user's own profile after they edit it: zero. Showing someone their own stale edit is the single fastest way to make an app feel broken.
- An analytics dashboard: minutes, and the user will assume it's delayed anyway.
Those four answers lead to four completely different designs. Skipping this question is how you end up with one Redis client, one CACHE_TTL constant, and the same 300 seconds applied to all four.
TTL is a strategy, and it's often the right one
There is a tendency to treat TTL-only caching as the lazy option. It isn't. It is the option with the fewest moving parts, and for a large class of data it is correct.
async function getCountryCatalogue(countryCode: string) {
const key = `catalogue:v3:${countryCode}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const fresh = await db.plan.findMany({ where: { countryCode, active: true } });
await redis.set(key, JSON.stringify(fresh), 'EX', 600);
return fresh;
}Ten minutes of staleness on a catalogue that changes twice a week is not a bug. It's a trade you made deliberately, and it costs you one line of code and zero coordination between services.
Two details in that snippet matter more than they look. The v3 in the key is a version prefix — when the shape of the cached value changes, you bump it and every old entry becomes unreachable instantly. No migration, no flush, no stale objects deserialising into the wrong type after a deploy. It's the cheapest safety mechanism in caching and it's routinely omitted.
The second is that the key includes every input that changes the answer. A cache key that omits a parameter isn't a cache — it's a bug that serves one country's plans to another.
The stampede that TTL creates
TTL has one well-known failure, and if you cache anything popular you will meet it.
A key expires. Two hundred concurrent requests all miss. All two hundred query the database with the same expensive query. The database, which was comfortable a moment ago, is now handling two hundred copies of your slowest query at once, and the latency spike causes retries, which cause more copies.
The fix is to let exactly one request rebuild while the others wait or serve stale:
async function getWithLock<T>(key: string, ttl: number, load: () => Promise<T>): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// Only one caller wins the lock; NX makes this atomic. The random token
// identifies *this* holder — see the release script below.
const lockKey = `${key}:lock`;
const token = crypto.randomUUID();
const won = await redis.set(lockKey, token, 'EX', 10, 'NX');
if (!won) {
// Someone else is rebuilding. Wait briefly, then read their result.
await sleep(50);
const retry = await redis.get(key);
if (retry) return JSON.parse(retry);
return load(); // fall through rather than stall the request
}
try {
const fresh = await load();
await redis.set(key, JSON.stringify(fresh), 'EX', ttl);
return fresh;
} finally {
// Compare-and-delete in one atomic step. A bare DEL would delete whatever
// lock is there — including a *different* caller's, if load() outran the
// 10s expiry and someone else has since acquired it.
await redis.eval(
`if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else return 0 end`,
1, lockKey, token,
);
}
}The finally is not optional. A lock that isn't released on the error path converts a transient database failure into ten seconds of every request bypassing the cache — which is the stampede you were preventing, now triggered by the error handling itself.
The token is not optional either, and it is the part that usually gets skipped. If load() takes longer than the lock's 10-second expiry, the lock is already gone and another request has legitimately taken it. A plain DEL at that point deletes their lock, and now two callers are rebuilding at once — the exact condition the lock existed to prevent, reintroduced by the cleanup. Checking the token before deleting costs one Lua script and removes the whole class of bug.
Explicit invalidation, and the coupling it buys
When staleness must be near zero, TTL stops being enough. You invalidate on write.
async function updatePlanPrice(planId: string, price: number) {
const plan = await db.plan.update({ where: { id: planId }, data: { price } });
await Promise.all([
redis.del(`plan:v3:${planId}`),
redis.del(`catalogue:v3:${plan.countryCode}`),
]);
return plan;
}This works, and it introduces a real cost that is worth naming: the write path now has to know every cache key that contains this data. Add a "featured plans" cache next quarter and this function must be updated — and if it isn't, you get stale data with no error anywhere. The knowledge is distributed across every writer, and nothing enforces that it's complete.
There are two ways to contain that.
Tag-based invalidation. Store the reverse mapping so writers name a concept rather than enumerating keys.
// When caching, record which tags this key belongs to.
// (ioredis command names throughout this post — node-redis v4 spells these
// sAdd/sMembers and takes an options object on set.)
await redis.sadd(`tag:plan:${planId}`, key);
await redis.sadd(`tag:country:${countryCode}`, key);
// When writing, drop everything under the tag.
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 updatePlanPrice calls invalidateTag with the plan's tag and does not need to know what caches exist. New caches register themselves.
Events instead of direct calls. Publish plan.updated and let each cache owner subscribe. This decouples properly but buys you eventual consistency: there is now a window between the write and the invalidation. Usually milliseconds, occasionally much longer when the consumer is behind. If your requirement was "zero staleness," an event bus does not deliver it — it just moves the staleness somewhere less visible.
The read-your-own-writes rule
Of all the caching bugs I've seen, this one produces the most user anger per line of code.
A user edits their profile. The write succeeds. The redirect loads their profile from cache. They see their old name. They edit again. Same result. They conclude the app is broken, and they're not wrong.
Global staleness tolerance and personal staleness tolerance are different numbers. Nobody minds that another user's profile is thirty seconds stale. Everyone minds about their own.
async function getProfile(userId: string, viewerId: string) {
// Viewers see cache. The owner always sees the truth.
if (userId === viewerId) return db.user.findUnique({ where: { id: userId } });
return getWithLock(`profile:v2:${userId}`, 60, () =>
db.user.findUnique({ where: { id: userId } }),
);
}Three lines. It removes an entire class of "the app didn't save my changes" reports, most of which were the app saving the changes perfectly and then showing a cached copy.
What actually goes wrong
Beyond the stampede and read-your-own-writes, three things account for most cache incidents I've dealt with.
Caching a failure. An upstream call times out, the handler returns null, and null gets cached for ten minutes. Now a transient blip is a ten-minute outage. Only cache successful results, and if you must cache negatives to protect against lookup floods, give them their own much shorter TTL.
Unbounded key growth. A key built from user-supplied input — a search query, a filter combination — means an attacker or a crawler can fill your cache with single-use entries and evict everything valuable. Bound the input space or hash it into a fixed set of buckets.
Deploy-time deserialisation errors. You add a field to the cached object's type. Old entries don't have it. Code reads value.newField.something and throws for as long as the old TTL lasts. This is the version prefix's entire reason for existing, which is why it belongs in the key from day one rather than being added after the first incident.
The part that generalises
Caching is not a performance trick you add at the end. It is a deliberate decision to serve data you know might be wrong, in exchange for speed and load reduction. That's a legitimate trade — most of the internet runs on it — but it is a trade, and trades should be made explicitly.
So write down, next to each cache, the two things that make it reviewable: how stale this can be before it matters, and what causes it to refresh. If you can't answer both in one sentence each, the cache isn't finished. It's just fast for now.
Filed under


