A product price kept showing the old value after an update. TTL-only caching was the problem; tag-based invalidation was the fix I still use - including on this site.
The e-commerce backend I built cached its product catalog in Redis. Catalog reads are the hottest path in a storefront and the data barely changes, so caching them is an easy win. I did the obvious thing: cache the response, set a TTL, move on.
It worked until an admin changed a price. The update landed in Postgres instantly, but the storefront kept serving the old price until the TTL happened to expire. And there is no good TTL here: shorten it and you throw away the hit rate you cached for in the first place; lengthen it and stale prices linger. No single number is both fresh and fast.
What actually fixed it was tag-based invalidation. Every cached entry is tagged with what it depends on - a product's entry carries that product's tag, a category listing carries the category's. When something changes I don't guess at which keys to expire; I invalidate everything under that tag. A price update touches exactly the entries that showed that price, and nothing else. Hit rates stay high because most entries are never disturbed, and staleness drops from 'up to one TTL' to 'a few milliseconds after the write'.
I trusted the pattern enough that this very site runs on it. Public pages are cached in Redis and tagged; publishing or editing a post invalidates that tag, and the next reader gets the new version. The rule I have settled on: a TTL is a safety net for the entries you forgot about, not your invalidation strategy. If you know when your data changes - and inside your own app you almost always do - invalidate on the change, not on a timer.