Cache invalidation is a distributed systems problem
The TTL is not a cache policy. It is a probability distribution, and somebody has to own it.
Vedant · 4 notes in the margin · reviewed 1mo ago
The TTL is not a cache policy. It is a probability distribution, and somebody has to own it.
Vedant · 4 notes in the margin · reviewed 1mo ago
Every team I have joined has had a cache, and every team has eventually been surprised by it. The surprise is always the same shape: something was correct, then it was not, and nothing in the logs said why.
nothing in the logs said why
The logs never say why because "why" is a semantic question and logs are a syntactic record. We eventually added a stale-read counter to the read path specifically so that this question had an answer.
On one machine, cache invalidation is a function call and you can stop thinking about it. You hold a reference to the map, you call delete, and the next reader either sees the new value or blocks until it is written. There is a happens-before edge and it is obvious.
There is a happens-before edge
Small correction: this is only true if the reader and the map are actually ordered. Under a per-key lock you have the edge; under a striped lock you have an edge only for keys in the same stripe, which is a weaker guarantee than it looks.
class LocalCache {
private entries = new Map<string, Entry>();
private hits = 0;
private misses = 0;
get(key: string): Entry | undefined {
const entry = this.entries.get(key);
if (!entry) { this.misses++; return undefined; }
if (entry.expiresAt < Date.now()) { this.entries.delete(key); this.misses++; return undefined; }
this.hits++;
return entry;
}
invalidate(key: string) { this.entries.delete(key); }
}
class LocalCache {
private entries = new Map<string, Entry>();
private hits = 0;
private misses = 0;
get(key: string): Entry | undefined {
const entry = this.entries.get(key);
if (!entry) { this.misses++; return undefined; }
if (entry.expiresAt < Date.now()) { this.entries.delete(key); this.misses++; return undefined; }
this.hits++;
return entry;
}
invalidate(key: string) { this.entries.delete(key); }
}
This works because the map and the reader are in the same process, ordered by one thread or guarded by one mutex. When invalidate returns, every subsequent get sees the new value. You can reason about it locally.
Now put the cache in Redis and the reader in fifty pods, and the guarantee quietly evaporates. DEL returns. The writer moves on. But a pod that read the key eight milliseconds ago still has it in a local LRU with a five-minute TTL, and that pod will happily serve the stale value for five more minutes.
You have not fixed a bug. You have introduced a distributed systems problem and given it a friendly name. The tell is that nobody can now state what the system is allowed to get wrong — and that was the entire value of the local version.
a friendly name
This is also why the local-cache version survives in so many codebases: it is the only version where correctness is *obvious*, and obviousness is a real engineering property that people will trade performance to keep.
39% of reads can be served stale at a 20s TTL.
There are exactly three coherent answers, and every production cache is some mixture of them. Knowing which mixture you are running is most of the work.
| Strategy | Freshness | Cost | Fails when |
|---|---|---|---|
| Write-through | Immediate on the writer side | Write latency doubles | The reader cannot see the writer's write — you need a barrier |
| Invalidate + TTL | Bounded by TTL | Cheapest to run | You accept staleness and must say so out loud |
| Push to all readers | Immediate | O(replicas) per write | A replica is down, or lagging, or you forgot to subscribe |
The third strategy is the one people reach for and then discover is a distributed system with a fan-out and no backpressure. If you push invalidations, you are building a broadcast protocol. You now have to think about what a subscriber does when it reconnects and has missed forty messages.
The interactive above assumes a key whose value changes on a Poisson-ish process. Real change is bursty — deploys, backfills, a customer uploading in bulk. Under bursty load the expected staleness is far worse than the TTL suggests, and the only defence is to make invalidation a first-class write-path event rather than hoping the timer covers you.
Pick invalidate-plus-TTL, set the TTL from the rate of change of the data, and write the TTL down next to the data. Then tell the people reading through the cache what window of staleness they are inside, because they will otherwise assume they are looking at truth.
The TTL is not a cache policy. It is a promise about how wrong you are willing to be, made in a config file that nobody reads.
The corollary, which cost me a very long week: a TTL is not a cache policy, it is a documented error budget. If you cannot write down the number, you do not have a cache, you have a source of intermittent lies.
Everything else is a way of deferring that conversation.
First time here? Set your depth once and every post honors it — skim the argument, read it properly, or take the footnotes too.
Full argument, prerequisites explained inline.
Claim a handle to fork this post into your own seedling.
3 notes from 3 readers, attached to specific sentences. Select any sentence in the text to add yours.
Answers are quotes from this post, not generated text — so they can be wrong by omission but never by invention. What it does not cover, it says so. If you have brought your own model key, a model summarizes those same quotes, and the quotes stay the source of truth.
Your key is used for your provider if you added one, otherwise the site's. Automatic stays on free-tier models only.
The claims this post makes most concretely — the ones with numbers, absolutes or prescriptions in them — put to a model that has read only the sentence in question. Every objection quotes the post, and a claim nothing can be said against is reported as standing.
It leans on 1 other post. 2 posts have picked it up since. Oldest at the top, so a claim resting on something written after it would be visible as an arc running upward.
This post has been revised 3 times. Leave an address and you will hear the next time it moves — once, and only for a substantial change, not a typo.