Caching gets pitched as free performance. Add Redis, put your slow reads behind it, watch the response times drop. And it does work — right up until the first bug report that says "the number is wrong," and you realize the number was right, the cache was just showing an old copy of it.
That report changed how I think about caching. A cache isn't a performance feature you bolt on; it's a second copy of your data that can disagree with the first one. Every cache you add is a promise that the copy is close enough to the truth for whatever's reading it. Sometimes that promise is easy to keep. Sometimes keeping it is harder than the slow query you were trying to avoid.
The question I ask before caching anything
I've stopped asking "is this slow?" as the first question, because slow-but-correct beats fast-but-wrong for anything that matters. Instead I ask three things in order:
- How often is this read, really? Caching something read once an hour saves nothing worth the complexity.
- How much staleness can the reader actually tolerate? Seconds? Minutes? Zero?
- What does it cost to recompute if the cache isn't there?
A good caching candidate reads a lot, tolerates being a little behind & is expensive to produce. When all three line up, a cache is close to a free win. When even one doesn't — especially the staleness one — I get cautious fast.
Where it clearly pays off
The sweet spot is data that's read constantly, changes rarely & nobody expects to be current to the millisecond. Reference and catalog-style data is the classic fit: category trees, configuration, lookups, the kind of thing that's the same for thousands of requests and only changes when someone edits it.
async function getCategoryTree() {
const cached = await redis.get("category:tree");
if (cached) return JSON.parse(cached);
const tree = await db.buildCategoryTree(); // expensive, rarely changes
await redis.set("category:tree", JSON.stringify(tree), "EX", 3600);
return tree;
}Read thousands of times an hour, changes maybe once a day & if a user sees a category tree that's a few minutes out of date, nothing bad happens. That's the whole checklist satisfied. This is what Redis is for.
Where I deliberately don't cache
Now the more useful half, because knowing where not to cache is what separates a cache that helps from one that generates incidents.
I don't cache money & I don't cache inventory. Anything a user expects to be exactly current — an account balance, a wallet, the count of items left in stock — is a place where a stale read isn't a minor UX annoyance, it's a correctness bug wearing a performance costume. Show someone a balance that's thirty seconds old and let them make a decision on it & you haven't made the page faster, you've made it lie. Sell the last unit of stock twice because both requests read a cached "1 available," and the cache just cost you real money and a real apology.
The tell is simple: if being wrong for even a few seconds causes a bad decision or a broken invariant, don't cache it. Read it fresh. The latency you save is not worth the class of bug you're buying.
Invalidation, honestly
"There are only two hard things in computer science" is a tired joke, but cache invalidation earns its place in it. Two approaches & they fail differently.
TTL — just let entries expire after some seconds — is simple and I default to it. Its weakness is that you're choosing a fixed window of allowed wrongness & you can't shorten that window without losing the benefit. Event-driven invalidation — actively evict the entry the moment the underlying data changes — is more correct but more work & it's easy to miss an eviction path and end up serving stale data forever with no TTL to eventually save you.
There's also a trap that only shows up under load. If a thousand entries share one expiry time, they all expire in the same instant & a thousand requests stampede the database at once to rebuild them — a self-inflicted spike right when traffic is highest. Spreading expiries out with a little randomness turns that cliff into a gentle slope.
// Don't let a whole class of keys expire on the same tick.
const ttl = 3600 + Math.floor(Math.random() * 300); // 60–65 min, spread out
await redis.set(key, value, "EX", ttl);The rule I actually operate by
Everything above collapses into one principle: a cache is an optimization, never the source of truth. The system has to be correct with the cache completely empty. If flushing Redis would produce wrong answers rather than just slow ones, the cache has quietly become load-bearing & that's a design bug regardless of how fast it made things.
So I build the correct, cache-free path first and make sure it's right. Then I add caching on top, only where the three questions say yes, treating it as a layer I could delete at any moment and lose speed but never correctness. Held to that line, Redis is a great tool. Let it drift across that line & it turns into the most confusing category of bug there is — the kind where every piece of code is correct and the answer is still wrong.