DEV Community

Muralidharan Lakshmanan
Muralidharan Lakshmanan

Posted on

Designing a Production-Ready Cache

Across this series we've covered why caching exists, how memory and TTL work, where a cache can live, cache-aside and the other patterns, invalidation, Redis vs. Memcached, and the specific ways caching goes wrong under load. Now let's put it together.

Imagine someone asks: "We have a high-traffic backend, the database is becoming a bottleneck — can we add Redis?" The easy answer is "yes, put Redis in front of the database." The architect's answer is closer to: "Maybe — let's understand the workload, the consistency requirements, the failure scenarios, and what happens when Redis isn't there."

Adding a cache isn't the architecture. Designing what happens around the cache is the architecture.


1. Start with the problem

Picture a typical backend: users, a load balancer, application servers, a database. It's receiving 20,000 requests/sec, but a lot of those requests are asking for the same handful of things over and over — the same product pages, the same lookups. The database keeps redoing work it already did a second ago. Database CPU sits at 85%, connections at 90%, average latency 250ms, P95 latency 600ms.

That's a good candidate for caching. But before reaching for Redis, it's worth asking: what data are we actually caching, how often does it change, how stale can it be, how much memory do we need, what happens if the cache disappears, and what happens during a traffic spike? Those five questions shape the actual design far more than the decision to add a cache in the first place.


2. Cache-aside is still the right starting point

For most applications, Cache-Aside is where I'd start — it's the pattern from Part 4, and it holds up here. The read path checks the cache, falls back to the database on a miss, and populates the cache for next time:

Product product = cache.get(key);

if (product == null) {
    product = database.findProduct(id);
    cache.put(key, product, ttl);
}

return product;
Enter fullscreen mode Exit fullscreen mode

It's easy to understand and easy to troubleshoot, and that second property matters more than it sounds like it should — production systems benefit enormously from designs an engineer can reason about quickly at 2am during an incident.

The write path is just as simple: update the database, then delete the cache entry, and let the next read rebuild it.

database.update(product);
cache.delete("product:" + product.getId());
Enter fullscreen mode Exit fullscreen mode

Simple. But production systems aren't always simple, and the rest of this post is about what "simple" needs around it before it's actually production-ready.


3. What if Redis is down?

This is one of the most important questions in the whole design, and we covered the mechanics in Part 6 and Part 7 — worth restating the number here because it's the one that should drive the rest of this architecture. If Redis is normally absorbing 99,000 of every 100,000 requests/sec, and it suddenly disappears, all 100,000 fall straight through to the database. That's a 100x increase, and most databases were never sized for it.

Cache failure handling is not an afterthought here — it's as important as the cache design itself.


4. Don't let cache failure become database failure

A local, in-process application cache is one of the simpler ways to blunt that failure. Frequently accessed data can be served from application memory without ever reaching Redis, which also cuts network latency on the common path. The cost is a second copy of the data and a second invalidation problem — use it deliberately, not by default.

Layered together, this becomes a multi-level cache: L1 in application memory, L2 in Redis, L3 the database itself, each layer catching what the one before it missed. The closer the data sits to the application, the faster it responds — but every additional layer is more complexity, so don't add L1 just because you can; add it because you've measured Redis taking more traffic than it should.


5. Redis needs high availability too

Once Redis is load-bearing, a single Redis instance is a single point of failure sitting right where you least want one. Production deployments typically add replication, automatic failover, clustering, or a managed Redis offering across multiple availability zones — the exact topology depends on your provider, but the principle doesn't: don't create a single point of failure in the component you're depending on for performance.

High availability isn't the same as zero downtime, though. A successful failover can still involve connection failures, a short window of elevated latency, retries, and topology changes. The application needs to handle a Redis reconnect gracefully — retry with backoff, then continue — rather than have a hundred threads hammer the reconnect at once.


6. TTL strategy, and why jitter matters at scale

We covered TTL as a freshness question back in Part 2 and Part 5: not "what TTL should I use" but "how stale can this specific data safely be." A product description tolerates an hour; an exchange rate tolerates almost nothing; inventory depends entirely on the business.

The scale-specific risk shows up on deploys. If an application restart loads 500,000 cache entries with a flat 60-minute TTL, a large fraction of them can expire together an hour later — a self-inflicted cache avalanche, the failure mode from Part 7. Adding jitter (TTL = 60 minutes + random(0–5 minutes)) spreads that expiration across a window instead of a single instant. It's a small decision that prevents a surprisingly large spike.

The same logic from Part 7 applies here too: preventing cache stampede with request coalescing, and watching for hot keys where traffic concentrates on one popular value regardless of how many total keys exist. Both are things this architecture needs to have an answer for, not things to solve for the first time when they show up in production.


7. Cache warming

Rather than letting a cold cache meet its first real traffic spike head-on, preload the data you already know will be popular — before Black Friday, before a product launch, before a ticket sale goes live. You don't want your first million users of the day to be the ones warming the cache for everyone after them.


8. What if the database is also slow?

A scenario worth designing for explicitly: Redis is healthy, but the database itself is slow, so a cache miss now takes five seconds instead of five milliseconds. Hundreds of application threads can end up waiting on that single slow path simultaneously, the connection pool exhausts, requests queue, and then they start timing out anyway.

This is why a production cache architecture still needs database connection limits, request timeouts, circuit breakers, bounded concurrency, and backpressure. Caching reduces how often you hit the database — it doesn't remove the need for the database path itself to fail gracefully when it's slow.


9. Bringing it together

Here's what all of the above looks like assembled into one system, rather than as separate techniques:

A production reference architecture: users go through a load balancer to application servers, which check a local L1 cache, then a Redis cluster with a primary and replica as L2, and finally the database — with TTL and jitter, invalidation, request coalescing, cache warming, circuit breakers, rate limiting, and monitoring all applying across every layer

This is no longer "we added Redis." It's a system with a specific, considered answer for what happens when any single layer of it fails.


10. But don't build all of this on day one

Looking at that diagram, it's tempting to implement everything at once — cluster, local cache, distributed locks, event-driven invalidation, warming, circuit breakers, multiple TTL strategies, full monitoring — right from the start. Don't.

Add complexity in response to a demonstrated problem: day one starts as App → Redis → DB, then a local cache gets added once Redis is measurably taking too much traffic, then request coalescing once stampedes on popular keys are observed, then event-driven invalidation once cache-aside invalidation is measurably lagging

Start with the simplest design that solves the actual problem: application, Redis, database. Then measure. If Redis is taking too much traffic, consider a local cache. If you're seeing stampedes, consider request coalescing. If invalidation is noticeably delayed, consider making it event-driven. Add complexity in response to a demonstrated problem — not a hypothetical one.


11. Observability: watch more than the hit ratio

The most common gap in caching implementations isn't a missing technique — it's not watching the right things. A 99% hit ratio looks great in isolation and tells you almost nothing about what happens during the other 1%, or what that number does under stress.

The dashboard worth actually building: cache health metrics including hit ratio, miss ratio, P95 cache latency, memory usage, evictions, hot keys, and Redis errors, with database fallback traffic highlighted as the metric that matters most because it shows what the rest of the system does when cache performance degrades

Every metric on that board earns its place, but the one worth watching above the rest is database fallback traffic — because it's the one that tells you, immediately, whether a Redis latency blip is staying contained or turning into database load, application latency, and eventually timeouts. Knowing "Redis is at 80% CPU" on its own tells you far less than knowing what that 80% is doing to everything downstream of it.


12. Security is part of the cache design, not separate from it

A cache holds application data, and "it's internal infrastructure" isn't a reason to skip authentication, encryption in transit, access control, network isolation, credential management, or tenant isolation. The specific trap worth naming: caching data without considering who's allowed to see it. A key like user:123:profile is not the same thing as profile:123 the moment the underlying data carries user-specific authorization — a cache key design mistake can quietly become a security bug.


13. Cache keys are part of the architecture, not an implementation detail

A good key is deterministic, unique, easy to read, and scoped appropriately: product:v1:123 beats a bare 123, because it leaves room for versioning and avoids collisions. User-specific data gets scoped explicitly (user:123:recommendations); tenant-specific data gets scoped explicitly too (tenant:45:product:123). Sloppy key design causes collisions, stale reads, security issues, and invalidation that's harder than it needs to be.

Versioning the key itself is a small trick that pays off repeatedly: when a cached object's shape changes — say, a currency field gets added to a product payload — bumping product:v1:123 to product:v2:123 means old and new cached shapes never collide, and a deploy doesn't need to worry about what the previous version of the code left behind in the cache.


14. What if the cache is completely lost?

This is the real test of whether something is "just a cache." If Redis disappears entirely, the application should be able to rebuild from the database as the authoritative source — the open question is whether the database can survive the temporary surge while that rebuild happens. Request coalescing, rate limiting, local caching, gradual warming, connection limits, and circuit breakers are all protections aimed at exactly this moment.

The goal is cache failure leading to degraded performance and then recovery — not cache failure leading to database overload, then application failure, then everything going down together.


15. The production checklist

Before I'd call a caching implementation production-ready, I'd want answers across seven areas: what we're caching and what the source of truth is; the expected hit ratio, latency, and memory footprint; the TTL for each data type and whether it's jittered; what happens if Redis is unavailable and whether the database can absorb the fallback; whether a stampede is possible and whether coalescing is in place; how invalidation works and what happens if it fails; whether Redis can scale horizontally and what happens at memory exhaustion; and finally, whether there are metrics, alerts, and a way to spot hot keys and cache-driven database spikes before a person has to find out the hard way.

If you can't answer most of those, the architecture probably isn't finished — even if it's already handling production traffic today.


The biggest lesson from this series

Caching was never really about Redis, or Memcached, or any particular technology. At its core, it's a trade-off.

What caching actually trades: performance, freshness, and complexity sit at the three corners of a triangle, and the right balance in the middle is never all three at once — you're always trading some freshness for performance, and accepting some complexity for both

We trade freshness for performance, and we accept some complexity as the price of doing that trade deliberately instead of by accident. The best caching architecture was never the one with the highest hit ratio on a dashboard — it's the one that finds the right balance of performance, consistency, reliability, and operational simplicity for what the business actually needs.


What's next

That closes out the conceptual side of this series — why caching exists, how it works, where it lives, how to read and write through it, how to keep it honest, what it's built out of, how it breaks, and how to design around all of that on purpose.


If you've taken a cache from "we added Redis" to something closer to this reference architecture, what was the first piece you added — and was it because you measured the problem, or because you got paged for it?

Top comments (0)