A customer support ticket came in about a product showing the wrong price, and after twenty minutes convinced I was looking at a database problem, I found the actual issue in Redis, a cached price from four days earlier that had simply never been invalidated when the product's price changed. That embarrassing, entirely preventable bug is the reason I now think about cache invalidation as the actual hard part of caching, not an afterthought to bolt on once the caching itself works.
My original implementation cached product data with a simple time-based expiration, a twelve-hour TTL, and called it done. This works fine for data that changes rarely, but our pricing data changes throughout the day for flash sales and inventory-based dynamic pricing, meaning customers could see stale prices for up to twelve hours after a legitimate change, which is exactly what that support ticket was reporting.
// naive version
async function getProduct(id) {
const cached = await redis.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const product = await db.products.findById(id);
await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 43200);
return product;
}
The fix was straightforward once I actually thought about it correctly, whenever a product's price updates, explicitly delete that product's cache key at the same time as the database write, rather than waiting for a TTL to naturally expire. This feels obvious in hindsight, but I'd built the caching and the price update logic in two completely separate pull requests weeks apart, and nobody connected the two concerns until a customer did it for us.
async function updateProductPrice(id, newPrice) {
await db.products.update(id, { price: newPrice });
await redis.del(`product:${id}`); // the line that was missing
}
Individual product caching was the easy half. Our category listing pages cached an entire list of products together under one key for performance, and updating a single product's price didn't touch that separate list cache key at all, meaning the listing page could show a stale price even after the individual product page was correctly invalidated and showing the new one, a genuinely confusing inconsistency for a customer clicking between the two.
I solved this by maintaining a Redis set of which list-cache keys included a given product, updated whenever a list was cached, so that invalidating one product could also invalidate every list cache that included it, rather than trying to guess which lists might be affected.
async function cacheProductList(categoryId, products, listKey) {
await redis.set(listKey, JSON.stringify(products), 'EX', 3600);
for (const product of products) {
await redis.sadd(`product:${product.id}:lists`, listKey);
}
}
async function invalidateProduct(id) {
await redis.del(`product:${id}`);
const listKeys = await redis.smembers(`product:${id}:lists`);
if (listKeys.length) await redis.del(...listKeys);
await redis.del(`product:${id}:lists`);
}
Even with explicit invalidation, I found a narrow window where a read request could fetch stale data from the database replica just before a write propagated, then re-cache that stale value right after the invalidation delete ran, effectively undoing the invalidation. Adding a short negative-cache lock during writes, a brief marker preventing re-caching for a couple hundred milliseconds after invalidation, closed that window in our load testing without adding noticeable latency.
Once I started actually graphing cache hit rate per key pattern, I found some data we were caching for twelve hours barely changed weekly, wasted database load we could have avoided with a much longer TTL, while other data we'd set generous TTLs on changed every few minutes and was serving stale data constantly despite explicit invalidation gaps we hadn't caught yet. Blanket TTL policies across different data types were quietly wrong in both directions.
Caching the read path is the easy, satisfying part that shows up immediately in performance graphs. Getting invalidation genuinely correct across individual records, related lists, and race conditions is where the real engineering effort lives, and it's the part I'd underestimated entirely until a customer noticed a four-day-old price before I did.