Cache invalidation is easier when the cache can expire itself
The claim The hardest part of caching is not storing a value; it is knowing when the stored value has become wrong. Teams reach for elaborate invalidation schemes — tracking every...
The claim
The hardest part of caching is not storing a value; it is knowing when the stored value has become wrong. Teams reach for elaborate invalidation schemes — tracking every place a piece of data is cached and deleting it the instant the source changes — and those schemes are where cache bugs live, because they require you to correctly anticipate every write path forever. A short expiry time is a cruder tool that solves most of the problem with none of the coordination, and for most small-business data it is the right default.
Why explicit invalidation is so error-prone
Explicit invalidation means that every time data changes, some code somewhere must remember to remove or update every cached copy of it. This works until it doesn't, and it stops working in a predictable way: someone adds a new place that writes the data, or a new place that caches it, and forgets to wire up the invalidation between them. Now the cache serves a stale value indefinitely, because nothing ever told it to expire, and the bug is invisible until a customer notices they are seeing old information — a price that changed, an availability that did not update, a status that is wrong.
The difficulty grows with the system. Each new feature is another write path to remember, another cache to invalidate, another chance to miss one. Explicit invalidation asks you to maintain a complete, correct map of every data dependency in your application, kept perfectly in sync as the application changes, forever. That is a standard almost no team meets.
Expiry turns correctness into a time bound
A time-to-live on the cache entry changes the guarantee. Instead of promising the cache is always current, you promise it is never more than N seconds out of date, and you get that promise for free, without tracking any write paths at all:
cache.set(key, value, ttl=60) # this value self-destructs in 60 seconds
Now the worst-case staleness is bounded and known: sixty seconds. If the underlying data changes, the cache does not need to be told — it will fetch fresh data within a minute regardless, because the old entry simply ceases to exist. You have traded perfect freshness for a small, bounded, predictable staleness, and in exchange you have eliminated the entire class of bugs where a forgotten invalidation serves stale data forever. For most data, that is an excellent trade.
Choose the TTL by how stale is tolerable
The right expiry time is a business question, not a technical one: how out of date can this specific piece of data be before it causes a real problem? The answer varies enormously by data type, and matching the TTL to the tolerance is the whole skill:
| Data | Tolerable staleness | TTL |
|---|---|---|
| Product description | Minutes to hours | 1 hour |
| Stock level shown on listing | Seconds to a minute | 30 seconds |
| Account balance | Must be current | Do not cache |
| Home page content | Minutes | 5 minutes |
The bottom row is the important discipline: some data must never be stale, and for that data the answer is not a clever cache but no cache. A financial balance, a security permission, the definitive answer to "did this payment succeed" — these are read from the source every time, because the cost of showing a wrong one exceeds any performance saving.
Combine expiry with invalidation, do not replace one with the other
Expiry and explicit invalidation are not rivals; the strongest pattern uses expiry as the safety net under invalidation. Invalidate explicitly where you can, for the fast correction — when a price changes, clear its cache entry so the change appears immediately — but set a TTL as well, so that any invalidation you forgot or that failed is self-correcting within the expiry window. The explicit invalidation gives you speed when it works; the TTL gives you correctness when the invalidation doesn't. You get the best case of immediate updates and the worst case of bounded staleness, instead of the best case of immediate updates and the worst case of permanent staleness.
The stampede to watch for
Expiry introduces one failure mode worth knowing: when a popular cached entry expires, every request that wanted it now misses at once and they all hit the database simultaneously to recompute the same value — a stampede that can overwhelm the source precisely because the item was popular. The fix is to let the first request recompute while the others briefly serve the slightly-stale old value, or to add a small random jitter to expiry times so that many entries do not all expire on the same tick:
ttl = 60 + random(0, 10) # spread expiries so they don't align
This matters only for your hottest cached items, but for those it is the difference between a smooth refresh and a periodic spike of load every time the popular entry turns over.
The default to adopt
Start with expiry, not invalidation. For each thing you cache, ask how stale it is allowed to be, set a TTL to match, and only add explicit invalidation on top where the staleness window is still too slow for a specific important case. This inverts the usual order — most teams build elaborate invalidation first and add expiry never — and it produces caching that is correct by default, because the worst thing that can happen is bounded staleness rather than the permanent wrongness that a missed invalidation quietly produces. The cache that expires itself cannot serve stale data forever, and that guarantee is worth more than the freshness you give up to get it.