Skip to content
All posts

Backend

Caching Without Invalidation Regret

Md Shamim Hossain June 5, 2026 4 min read 1,977 views Updated Oct 26, 2026

Most caching bugs are not cache misses. They are cache hits returning something that stopped being true an hour ago.

More ways to share

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:

  • rememberForever is safe here because the key changes on every edit. There is no stale state to expire; there is only unreachable state, which a periodic cache:prune-stale-tags job 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:

  1. 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.
  2. What in the key changes when the value changes? If nothing does, the cache is a bug factory regardless of TTL.
  3. 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

1 comment

Sign in to join the discussion. Comments are published under your name.

Markdown-style emphasis and `code` are supported. 0 / 2000
Held for moderation
  1. H

    Hannah Lin

    Short, concrete, and it does not waste a paragraph telling me what a query plan is. More posts like this please.
Enter your credentials to continue to your workspace.
Email address
Remember me
Don't have an account? Sign up
Enter your details below to create your account
Name
Email address
Password
Confirm password
Already have an account? Log in
Enter your email and we will send you a reset link
Email address
Or, return to log in