Blog
Caching With Redis When Cache Invalidation Is the Hard Part
Adding Redis in front of your database is easy, but choosing the right caching pattern and invalidation strategy determines whether you speed things up or serve stale lies.
Putting Redis in front of Postgres or Mongo is one of the highest-leverage moves a backend engineer can make, often turning a hundred-millisecond query into a sub-millisecond memory read. The first version is trivial: check the cache, fall back to the database, store the result. The hard part, as the old joke goes, is cache invalidation, because the moment your cached copy and your source of truth disagree, you are serving users incorrect data with great confidence and speed. Effective caching is less about Redis commands and more about a disciplined strategy for when to populate, when to expire, and how to keep the cache honest as the underlying data changes. This piece covers the canonical caching patterns, the expiration mechanics Redis gives you, and the failure modes like stampedes and stale reads that separate a cache that helps from one that quietly corrupts your users' experience.
The Cache-Aside Pattern
Cache-aside, also called lazy loading, is the workhorse pattern most applications start with. Your code checks Redis for a key first; on a hit it returns immediately, and on a miss it queries the database, writes the result back into Redis with an expiry, then returns it. The cache only ever holds data someone actually requested, so it stays naturally scoped to your real working set. The application owns all the logic, which makes it easy to reason about and to drop into an existing Express or NestJS service. The downside is that the first request for any key always pays the full database cost, and if your code forgets to invalidate on writes, the cache can drift out of sync. Cache-aside is the right default precisely because it is explicit and gives you full control over both population and invalidation.
Write-Through And Write-Behind
Where cache-aside populates on reads, write-through populates on writes: whenever you update the database, you also update the cache in the same operation, keeping them consistent so reads almost always hit. This trades slightly slower writes for fresher cache contents and fewer misses. Write-behind, or write-back, goes further by writing to the cache first and flushing to the database asynchronously, which makes writes fast but risks losing data if Redis dies before the flush completes. Most application-level systems avoid true write-behind because the durability risk is rarely worth it, but write-through pairs nicely with cache-aside reads. The practical takeaway is that write patterns and read patterns are separate decisions you can mix. A common solid combination is cache-aside reads with explicit cache updates or deletions on every write path, giving you good hit rates without the durability gamble.
TTLs And Expiration Mechanics
Every cached value should generally carry a time-to-live so it cannot live forever and drift arbitrarily far from the truth. Redis lets you set a TTL when writing a key, after which it expires automatically. Redis removes expired keys lazily when they are next accessed and also samples and evicts them in a background process, so an expired key consumes memory until one of those happens. Choosing a TTL is a balance: too short and you lose the caching benefit by constantly refetching, too long and you serve stale data and risk memory pressure. A useful refinement is adding small random jitter to TTLs so that many keys created together do not all expire at the same instant, which would otherwise dump a synchronized flood of misses onto your database and undo the protection the cache was supposed to provide.
Invalidation On Writes
Time-based expiry alone is not enough when data changes and users must see the change immediately, like a profile edit or a price update. The cleanest approach is to invalidate explicitly: when a write modifies a record, delete the corresponding cache key so the next read repopulates from fresh data. This delete-on-write strategy is simpler and safer than trying to update the cached value in place, because computing the new cached representation can be error-prone and the delete-then-lazy-reload path is easy to reason about. The challenge is mapping a database change to every cache key it affects, which gets tricky when one entity appears in several cached aggregates or lists. Keeping your key naming consistent and predictable, so you can derive exactly which keys to invalidate from the record that changed, is what makes this discipline maintainable.
Preventing Cache Stampedes
A cache stampede happens when a popular key expires and dozens of concurrent requests all miss simultaneously, then all hammer the database to recompute the same value at once. Under load this can overwhelm the database precisely when traffic is highest. One defense is a lock: the first request to miss acquires a short-lived lock in Redis, recomputes the value, and writes it, while other requests briefly wait or serve slightly stale data instead of piling onto the database. Another approach is early recomputation, refreshing a key probabilistically before it actually expires so the refresh happens in the background rather than during a thundering herd. The TTL jitter mentioned earlier also helps by desynchronizing expirations. Recognizing that your hottest keys are exactly the ones most vulnerable to stampede is the insight that motivates building these protections before an outage forces the lesson.
Choosing An Eviction Policy
Redis is an in-memory store, so it can run out of memory, and what happens then depends on the eviction policy you configure with maxmemory. The default in many setups is to return errors on writes once memory is full, which is rarely what a cache wants. For caching workloads you typically choose allkeys-lru, which evicts the least recently used keys to make room, or allkeys-lfu, which evicts the least frequently used. The volatile variants only evict keys that have a TTL set, which lets you protect certain keys from eviction by leaving them persistent. Picking the right policy matters because a misconfigured Redis can either reject writes during traffic spikes or evict keys you needed to keep. Treat eviction policy as a deliberate capacity-planning decision tied to how you use Redis, not a setting to leave at its default.
What To Cache And What Not
Not all data benefits from caching, and caching the wrong things wastes memory while adding invalidation complexity for little gain. The best caching candidates are reads that are frequent, expensive to compute, and tolerant of brief staleness, like rendered pages, expensive aggregate queries, or rarely-changing reference data such as configuration and product catalogs. Data that changes on nearly every read, or that must always be perfectly current like a bank balance during a transaction, is a poor fit and may be safer read directly from the source. Highly personalized data with low reuse, where each cached entry is read only once before changing, often costs more to cache than it saves. The skill is identifying the small set of hot, stable, expensive reads that dominate your load, since caching those handful of queries usually delivers the overwhelming majority of the benefit.
Monitoring Hit Rates
A cache you do not measure is a cache you cannot trust, because a low hit rate means you are paying the cost and complexity of Redis while still hitting the database constantly. Redis exposes keyspace hit and miss counters through its INFO command, and the ratio of hits to total lookups is the headline metric to watch. A healthy cache for hot data often sits well above ninety percent hits; a rate near fifty percent suggests your TTLs are too short, your keys are too granular, or you are caching data with little reuse. Watch eviction counts too, since high evictions mean memory pressure is throwing away keys before they expire, silently dragging the hit rate down. Treating these metrics as first-class observability, graphed alongside database load, lets you tune TTLs and key design with evidence instead of guessing whether the cache is actually earning its keep.