Redis Caching Strategies That Actually Move the Needle
Cache-aside: the default, and why
Cache-aside — check the cache, on miss read the database and populate the cache, return the value — is the right default for the overwhelming majority of read-heavy endpoints. It's simple, it degrades gracefully (a Redis outage means every request falls through to the database instead of failing outright), and it doesn't require the write path to know anything about caching at all.
async function getProduct(id) {
const cached = await redis.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const product = await Product.findById(id).lean();
await redis.set(`product:${id}`, JSON.stringify(product), "EX", 60);
return product;
}
Write-through, for data where staleness is unacceptable
For data where even a 30-second-stale read is a real problem — a live inventory count during a flash sale, say — write-through is worth the extra complexity: the write path updates the cache synchronously as part of the write, so a read immediately after a write is guaranteed fresh, with no TTL race. The cost is that every write path now has a caching concern baked into it, which is more code to get right and more places a bug can leave the cache and the database disagreeing.
The invalidation problem, honestly
"There are only two hard problems in computer science: cache invalidation and naming things" is a joke because it's true. The failure mode that actually happens in production is granularity: caching an entire category's product list under one key means any single product update in that category has to invalidate the whole list, which — during an active admin editing session — means the cache barely helps at all. Caching per-product and composing list views from individually-cached entries means a single edit invalidates one small key, and the list-level cache miss rate stays low even under active editing.
Pub/sub for invalidation across multiple app servers
Once you're running more than one application server, an in-memory "just clear the local cache" approach breaks — server B doesn't know server A's cache is stale. Redis pub/sub solves this cleanly: on invalidation, publish the key to a channel every server subscribes to, and each server clears its own local reference (if you're layering an in-process cache in front of Redis) or simply relies on Redis itself being the single shared cache, sidestepping the multi-server problem entirely by not caching locally at all.
The TTL number that's usually wrong
Teams either set TTLs far too long (data feels "stuck" after an edit, generating support tickets) or skip TTLs from fear of staleness, defeating the point of caching. My default is a short TTL — 30 to 60 seconds — combined with explicit invalidation on write. This gets you the safety net of an eventual expiry even if an invalidation is missed somewhere, plus near-immediate freshness on the common path where the write and invalidation happen together. It is very rarely the right call to cache without any TTL at all — a cache that never expires on its own is one bad deploy away from serving stale data indefinitely.
What a cache can't fix
A cache in front of a slow, unindexed query makes the cache hit fast and the cache miss just as slow as before — it doesn't fix the underlying query, it just hides it most of the time until the cache is cold (a deploy, a Redis restart) and every request hits the slow path simultaneously. Fix the query first. Cache what's already fast to make it faster and to reduce database load, not as a substitute for query optimization.