Hannah Lin
Most caching bugs are not cache misses. They are cache hits returning something that stopped being true an hour ago.
Link copied to clipboard
A cache miss is a performance problem. A stale cache hit is a correctness problem. Teams spend most of their effort on the first and get burned by the second.
The taxonomy that actually helps
Before choosing a TTL, classify what you are caching. The class determines the invalidation strategy, and the strategy determines whether the cache is safe.
| Class | Example | Invalidation | Safe TTL |
| --- | --- | --- | --- |
| Immutable | A published post's rendered HTML at a given version | None needed | Infinite |
| Derived, bounded staleness OK | Category post counts | Event-driven | Hours |
| Derived, must be exact | Account balance | Event-driven + versioning | None — do not cache |
| Expensive, tolerance unknown | Third-party API response | TTL only | Depends on the contract |
The dangerous cell is the third row. If the value must be exact and you cannot guarantee invalidation, caching it is not a performance optimisation — it is a correctness bug with a delay fuse.
Key derivation decides everything
Two rules about keys, and both are load-bearing.
The key must encode everything the value depends on. If the rendered output changes when the post's body changes, the version must be in the key:
$key = "post:{$post->id}:html:v{$post->updated_at->timestamp}";Now a stale entry is unreachable rather than invalidated. The old value is still in the cache, but nothing will ever look it up again, and it expires on its own. This is vastly more reliable than trying to actively delete the right keys, because it cannot be defeated by a missed delete path.
The key must not depend on anything volatile. post:{$id}:html:user:{$userId} is fine if the output really varies per user. If it does not, you have just multiplied your cache size by your user count for no benefit and dropped the hit rate to near zero.
Version-keyed caching in practice
The pattern that has caused me the fewest problems:
final class CachedPostHtml
{
public function __construct(protected MarkdownRenderer $renderer) {}public function get(Post $post): string
{
// The version is the post's own content hash. Any edit
// produces a new key; no delete is ever required.
$version = substr(md5($post->body.$post->updated_at), 0, 12);
$key = "post:{$post->id}:html:{$version}";
return Cache::rememberForever($key, function () use ($post): string {
return $this->renderer->render($post->body);
});
}
}
```
Properties worth noting:
rememberForeveris safe here because the key changes on every edit. There is no stale state to expire; there is only unreachable state, which a periodiccache:prune-stale-tagsjob or a TTL on the underlying store handles.- The version derives from the content, not from a manual
bump()call that a developer must remember to make. - There is no delete path, so there is no delete path that can be forgotten.
Where TTL is the right tool
Sometimes you genuinely cannot know when a value changes — a third-party API, an external feed. Then TTL is your only lever, and the question is not "how long should this live" but "how wrong is it acceptable for this to be."
That is a product question, not an infrastructure question, and it should be answered by the person who owns the consequence. "Stock levels may be up to five minutes stale and could show an out-of-stock item as available" is a sentence a product owner should have to agree to.
Write it in the ADR. It is a decision, and someone will ask about it later.
The three-question audit
Before adding a cache entry, answer:
- What is the worst thing that happens if this is stale? If the answer is "a user sees outdated information and makes a decision on it," either version-key it or do not cache it.
- What in the key changes when the value changes? If nothing does, the cache is a bug factory regardless of TTL.
- How will I notice if it goes stale? A cache that can go stale silently needs a monitor, or it needs a shorter TTL, or it needs to not exist.
The uncomfortable conclusion
Caching is not primarily a performance technique. It is a consistency model choice, and treating it as a performance knob is how teams end up with a system that is fast, correct in every test, and intermittently wrong in production in a way nobody can reproduce.
Add the cache when the measurement says you need it, version the key from the content, and write down how stale it is allowed to be. That is the whole discipline.
Keep reading
Discussion
-
HShort, concrete, and it does not waste a paragraph telling me what a query plan is. More posts like this please.