<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Muralidharan Lakshmanan</title>
    <description>The latest articles on DEV Community by Muralidharan Lakshmanan (@muralidharan_lakshmanan).</description>
    <link>https://dev.to/muralidharan_lakshmanan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3984387%2F7875712e-b548-4f68-95d6-01053bcb6c52.png</url>
      <title>DEV Community: Muralidharan Lakshmanan</title>
      <link>https://dev.to/muralidharan_lakshmanan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/muralidharan_lakshmanan"/>
    <language>en</language>
    <item>
      <title>Designing a Production-Ready Caching Strategy</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Fri, 28 Aug 2026 00:43:09 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/designing-a-production-ready-caching-strategy-1dhj</link>
      <guid>https://dev.to/muralidharan_lakshmanan/designing-a-production-ready-caching-strategy-1dhj</guid>
      <description>&lt;p&gt;This series started with a simple question: why do we need caching? The simple answer — "it makes applications faster" — is true, but fourteen parts later it's clear that's not the whole story. Caching touches application performance, database capacity, scalability, consistency, availability, cost, security, and resilience all at once. Which is exactly why the most common mistake is opening a caching discussion with &lt;em&gt;"should we use Redis?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's not the first question. The better one: &lt;strong&gt;where is our system actually spending time and resources, and can caching safely reduce that cost?&lt;/strong&gt; Let's build the practical framework this whole series has been pointing toward.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Start with the problem, not the technology
&lt;/h2&gt;

&lt;p&gt;If a database is becoming a bottleneck behind a fleet of application instances, the instinct is to reach for Redis immediately. Resist it. First understand what's actually slow, what's expensive, what's repetitive, what's genuinely frequent. If 60% of database queries turn out to be hitting the same 5% of data, &lt;em&gt;now&lt;/em&gt; caching has a clear justification — not because caching is generally good, but because this specific pattern is exactly what it's built to solve.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Find the hot data
&lt;/h2&gt;

&lt;p&gt;Not all data is equally worth caching. If a database holds 10 million products but traffic concentrates on 50,000 of them, those 50,000 are the hot data — and caching just those instead of attempting to cache everything cuts memory consumption, cost, evictions, and operational complexity all at once. A good caching strategy starts from actual traffic patterns, not from an assumption that more coverage is automatically better.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Measure before you cache
&lt;/h2&gt;

&lt;p&gt;Before touching Redis, measure the system as it exists: API latency, database latency, database QPS, CPU, memory, connection pools, slow queries, request frequency, error rate. If &lt;code&gt;GET /products/{id}&lt;/code&gt; averages 220ms with 180ms of that spent in the database at 5,000 requests/sec, the opportunity is obvious — caching could plausibly bring that down to 10–20ms. But that's a hypothesis until it's measured, not a promise that exists just because Redis is available.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Decide whether the data is actually cacheable
&lt;/h2&gt;

&lt;p&gt;Five questions, in order: is it expensive to retrieve? Is it requested repeatedly? Does it change less often than it's read? Can stale data be tolerated? Is it sensitive or personalized? The first two determine whether caching would help at all. The third is one of the strongest signals that exists. The fourth is where real risk lives. The fifth determines whether a shared cache is even safe to use.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Think about staleness before anything else
&lt;/h2&gt;

&lt;p&gt;A product description changing every few weeks can tolerate an hour-long TTL without anyone noticing. An account balance cannot tolerate the same treatment for even a few minutes. Picture this as a spectrum: images, product descriptions, and country lists sit on the low-freshness-requirement end; account balances, payment status, inventory, and authorization sit on the high end. The further right something sits, the more careful the caching strategy around it needs to be — this was the same axis Part 13 plotted in detail, and it's worth carrying forward as the first filter on every new caching decision.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Choose the right layer — not just Redis
&lt;/h2&gt;

&lt;p&gt;Assuming everything belongs in Redis is one of the most common architectural mistakes in this whole space. There's a real hierarchy: browser cache, CDN or edge cache, application-local cache, distributed cache, and the database underneath all of it. Each layer exists to solve a different problem, and picking the wrong one for a given piece of data means paying for infrastructure that isn't actually earning its place.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Browser cache
&lt;/h2&gt;

&lt;p&gt;Best for images, CSS, JavaScript, fonts, and other static assets — a hit here means the request never reaches your infrastructure at all, which is about as cheap as a cache hit gets.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. CDN cache
&lt;/h2&gt;

&lt;p&gt;Excellent for images, video, static assets, public API responses, and anything worth serving from a location physically closer to the user than your origin. Instead of every request from Atlanta, California, and London all traveling to one backend, each can be served from an edge location near it instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Local application cache
&lt;/h2&gt;

&lt;p&gt;For data an application checks constantly and that barely changes — feature flags, configuration, country codes, static business rules — an in-memory local cache means no network call at all. The trade-off: every application instance holds its own copy, which is exactly the consistency question Part 5's invalidation techniques exist to answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. Distributed cache
&lt;/h2&gt;

&lt;p&gt;For data that needs to be shared identically across every application instance — sessions, product data, frequently accessed query results, shared configuration, recommendations, rate-limiting counters — a distributed cache like Redis is where that consistency actually gets solved centrally.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Multi-level caching, used deliberately
&lt;/h2&gt;

&lt;p&gt;Sometimes the right answer combines layers: check a local cache first, fall through to Redis on a miss, fall through to the database on a miss from there. This can meaningfully cut network traffic to Redis while keeping the shared cache's consistency guarantees. But every additional layer is additional complexity — add one because a measured problem calls for it, not because the architecture diagram has room for another box.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. Choose the pattern deliberately
&lt;/h2&gt;

&lt;p&gt;Cache-Aside remains the strongest default: the application checks the cache, falls back to the database on a miss, and populates the cache for next time. It's simple, flexible, and easy to reason about during an incident — which matters more than it sounds like it should. Write-Through is worth reaching for when the write path itself needs the cache updated as part of the transaction, at the cost of more coordination complexity. Neither is universally correct; the choice follows your read/write ratio, consistency requirements, failure behavior, and who actually owns the data.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. Design cache keys with real care
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;product:12345&lt;/code&gt; looks simple, but &lt;code&gt;product:v2:12345&lt;/code&gt;, &lt;code&gt;customer:12345:profile&lt;/code&gt;, and &lt;code&gt;search:v3:headphones:page:2&lt;/code&gt; show how much a key needs to carry as a system grows. Good keys are predictable, unique, consistent, easy to debug, and versionable — and versioning specifically pays off the moment a cached object's shape changes: bumping &lt;code&gt;product:v1:123&lt;/code&gt; to &lt;code&gt;product:v2:123&lt;/code&gt; means the application never has to understand two incompatible formats living side by side.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. Choose TTL deliberately, and don't make it uniform
&lt;/h2&gt;

&lt;p&gt;TTL should follow the data, not a company-wide default: configuration might reasonably get an hour, product descriptions thirty minutes, recommendations five minutes, search results two. A single global TTL applied everywhere is usually the tell that caching was bolted on rather than actually designed around the data underneath it.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. Plan invalidation before you need it
&lt;/h2&gt;

&lt;p&gt;TTL-based expiration, explicit invalidation on write, and event-driven invalidation each solve the same underlying problem differently. The genuinely hard part was never deleting one key — it's knowing every cached representation a single database change actually touches. One product update might need to invalidate the product itself, several search result pages, a recommendations cache, and a trending list. Cache design has to account for those relationships up front, not discover them during an incident.&lt;/p&gt;




&lt;h2&gt;
  
  
  16. Write staleness tolerance down as a real requirement
&lt;/h2&gt;

&lt;p&gt;Not as a vague feeling, but as an actual number per data type: product description, an hour; recommendations, ten minutes; search results, two minutes; inventory, seconds; account balance, near real-time; static images, days. Writing this down turns an abstract debate into something engineers can actually build and test against.&lt;/p&gt;




&lt;h2&gt;
  
  
  17. Plan for cache failure before it happens in production
&lt;/h2&gt;

&lt;p&gt;What happens when Redis is completely unavailable? The honest menu of options — database fallback, serving a local stale copy, graceful degradation, rejecting the request, or returning a sensible default — and the right one depends entirely on what that specific cache was doing. This is the whole subject of the previous part in this series, and it deserves to be decided at design time, not discovered during the outage itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  18. Protect the database as a first-class design concern
&lt;/h2&gt;

&lt;p&gt;At a 98% hit ratio and 50,000 requests/sec, the database normally sees roughly 1,000/sec. If the cache disappears, it could suddenly face all 50,000 — a 50x jump. Connection limits, concurrency limits, timeouts, circuit breakers, and load shedding aren't optional extras bolted onto a caching layer; caching and database capacity planning are the same planning exercise, not two separate ones.&lt;/p&gt;




&lt;h2&gt;
  
  
  19. Monitor the cache — and its relationship to everything downstream
&lt;/h2&gt;

&lt;p&gt;Hit ratio, miss ratio, latency, error rate, evictions, memory, connection count, CPU, network traffic, hot keys, expired keys — all worth tracking. But the metric that tells the real story is the &lt;em&gt;relationship&lt;/em&gt;: cache hit ratio against database QPS against database CPU against application latency, watched together rather than in isolation.&lt;/p&gt;




&lt;h2&gt;
  
  
  20. A high hit ratio isn't automatically a healthy system
&lt;/h2&gt;

&lt;p&gt;At 100 million requests and a 95% hit ratio, the remaining 5% is still 5 million database requests — potentially enormous depending on what each one costs. The right question isn't "is our hit ratio impressive" — it's "how much actual load is the cache removing from the system." A 90% hit ratio can be fantastic in one system and genuinely inadequate in another, depending entirely on what that other 10% costs to serve.&lt;/p&gt;




&lt;h2&gt;
  
  
  21. Watch for hot keys specifically
&lt;/h2&gt;

&lt;p&gt;A single key responsible for 30% of all cache traffic can become a bottleneck even while every aggregate metric looks completely healthy — the exact trap Part 11 walked through with cluster averages hiding a single overloaded node. Local caching, replication, request coalescing, precomputation, and sharding strategy all apply here, and hot-key analysis becomes more important, not less, the larger a system gets.&lt;/p&gt;




&lt;h2&gt;
  
  
  22. Plan capacity ahead of the wall, not into it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fioupdxmoydil8v9ymuvl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fioupdxmoydil8v9ymuvl.png" alt="Capacity planning is a running calculation, not a guess: 40 GB of current usage, multiplied by 1.5 for expected growth to reach 60 GB, multiplied by 1.3 for operational headroom to land at roughly 78 GB of required capacity" width="800" height="741"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Keys times average object size times overhead, plus replication, plus expected growth, plus deliberate headroom — worked through with real numbers rather than waiting for a memory alert at 99%. The exact formula depends on the technology, but the discipline is universal: size the cache before it's full, not in response to it being full.&lt;/p&gt;




&lt;h2&gt;
  
  
  23. Eviction is a policy decision, not a safety net
&lt;/h2&gt;

&lt;p&gt;"Redis will just evict something if it fills up" is true and also not reassuring on its own — &lt;em&gt;what&lt;/em&gt; gets evicted determines whether that's fine or a production incident. If a genuinely hot key gets evicted, the result is repeated misses landing straight on the database. LRU, LFU, TTL-based, and random eviction each encode a different assumption about access patterns, and the right one should be chosen deliberately and watched afterward, not left on whatever the default happened to be.&lt;/p&gt;




&lt;h2&gt;
  
  
  24. Security is part of cache design, not an afterthought
&lt;/h2&gt;

&lt;p&gt;Could one user ever receive another user's cached data? A cache key like &lt;code&gt;profile&lt;/code&gt; instead of &lt;code&gt;profile:user:123&lt;/code&gt; is exactly how that happens — User A's request and User B's request colliding on the same shared entry. For personalized data, key isolation, authorization, encryption, the shared-vs-private distinction, and CDN caching rules all need deliberate attention. Performance should never be purchased at the cost of data isolation.&lt;/p&gt;




&lt;h2&gt;
  
  
  25. Don't cache everything
&lt;/h2&gt;

&lt;p&gt;Maybe the single most important lesson in this entire series. Caching adds real, ongoing complexity: expiration, invalidation, consistency, monitoring, and failure handling, all layered on top of the application that already existed without it. If a query takes 2ms and runs 10 times a second, a caching layer around it is very likely adding more complexity than it's returning in value.&lt;/p&gt;




&lt;h2&gt;
  
  
  26. Caching has a real cost, and it belongs in the decision
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhhklbitb7z2ddggfss87.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhhklbitb7z2ddggfss87.png" alt="Cache value is a sum, not a given: performance improvement, database savings, and scalability add up on one side, while infrastructure cost and operational complexity subtract on the other, with the result being the actual cache value — not simply " width="799" height="209"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Memory, infrastructure, replication, network traffic, operations, monitoring, development effort, and debugging complexity are all real costs, not hypothetical ones. Cache value isn't "cache = faster" — it's performance improvement plus database savings plus scalability, minus infrastructure cost, minus operational complexity. The goal was never maximum caching. It's maximizing that whole equation, including the parts that subtract.&lt;/p&gt;




&lt;h2&gt;
  
  
  27. A production caching architecture, assembled
&lt;/h2&gt;

&lt;p&gt;Putting it together for something like an e-commerce platform: a CDN in front for public and static content, a local cache for extremely hot and stable data, Redis for shared application data, and the database underneath as the one source of truth. Each layer earns its specific job rather than one technology trying to solve every problem at once — which is a far more effective default than reaching for a single caching tool and stretching it to cover everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  28. The full decision pipeline
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffa4my64wpltpiopw1cd8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffa4my64wpltpiopw1cd8.png" alt="From " start="" width="799" height="289"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Let's put Redis in front of the database" skips every step in that pipeline after the first box. Screening a candidate is the easy 20% of the work; choosing the layer, the pattern, the invalidation strategy, the failure behavior, and the monitoring is the harder 80% that actually determines whether the caching decision holds up in production.&lt;/p&gt;




&lt;h2&gt;
  
  
  The production checklist
&lt;/h2&gt;

&lt;p&gt;Before calling a caching strategy complete, it should have real answers across eight areas: what's being cached and why; which architectural layer it belongs in; which pattern governs how the application talks to it; whether the keys are unique, predictable, versioned, and properly isolated per user; whether TTL, jitter, and invalidation are all defined; what happens on a Redis outage, a timeout, a retry storm, or a stampede; whether memory sizing, growth, replication, and eviction are planned; and whether hit ratio, latency, errors, evictions, memory, hot keys, and database impact are all actually observable. Answering all of these is the difference between having added a cache and having designed a caching strategy.&lt;/p&gt;




&lt;h2&gt;
  
  
  What fifteen parts actually taught
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdmf5xmii8eul880t32ql.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdmf5xmii8eul880t32ql.png" alt="Fifteen parts, one question asked from every angle: foundations covering why caching exists and how it works, architecture covering where to cache and how to invalidate it, technology covering Redis versus Memcached and production design, building it hands-on with Spring Boot and testing, reach and scale covering CDN and real-world patterns, and resilience covering failure design and this closing production strategy" width="799" height="305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We opened this series with &lt;em&gt;"Caching Is Simple… Until It Isn't,"&lt;/em&gt; and fifteen parts later that title still holds up exactly. The idea starts simple: don't calculate or retrieve the same thing twice. At production scale, that simple idea becomes an entire architectural discipline — covering why caching matters, how it actually works, where it should live, the patterns for reading and writing through it, the hardest problem in caching (invalidation), Redis versus Memcached, the specific ways caching goes wrong under load, what a production-ready architecture looks like, building one for real with Spring Boot, proving it works through testing, scaling it past a single node, reaching beyond Redis into CDNs and HTTP caching, matching real scenarios to real strategies, designing for the moment it fails — and now, pulling every one of those threads into one practical framework.&lt;/p&gt;

&lt;p&gt;Caching was never really a technology decision. Redis is a technology. Memcached is a technology. A CDN is a technology. Browser caching is a technology. But deciding &lt;em&gt;what&lt;/em&gt; should be cached, &lt;em&gt;where&lt;/em&gt; it should live, &lt;em&gt;how long&lt;/em&gt; it should survive, &lt;em&gt;how&lt;/em&gt; it gets invalidated, and &lt;em&gt;what happens&lt;/em&gt; when it fails — that's architecture, and it's where good caching design actually begins.&lt;/p&gt;




&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;p&gt;If this whole series had to compress into a single sentence, it would be this: &lt;strong&gt;don't cache because you can — cache because you understand the problem you're solving, and you understand exactly what happens when the cache is wrong, stale, full, or completely gone.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the mindset that turns caching from a performance trick into a production-grade architectural capability. Thanks for reading all the way through — it's been a genuinely long build.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Of everything covered across this series, which part changed how you'd actually design a cache the most? I'm curious which one lands differently once you've seen the whole arc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>systemdesign</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>When the Cache Goes Down: Designing for Failure</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Wed, 26 Aug 2026 14:43:00 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/when-the-cache-goes-down-designing-for-failure-2ig5</link>
      <guid>https://dev.to/muralidharan_lakshmanan/when-the-cache-goes-down-designing-for-failure-2ig5</guid>
      <description>&lt;p&gt;Most of this series has been about what happens when caching works: request, hit, fast response. But production doesn't stay in that state forever. Redis becomes unavailable, a network path breaks, a node crashes, connections exhaust, a deployment clears the cache, a config mistake causes mass expiration, a region goes dark. Eventually, cache goes down.&lt;/p&gt;

&lt;p&gt;The question that actually matters is what happens to the application next. A poorly designed system turns a Redis outage into database overload into an application outage, in seconds. A well-designed one turns it into degraded performance, a protected database, and an application that stays up. That gap is the entire subject of this post.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The most important principle
&lt;/h2&gt;

&lt;p&gt;A cache should usually be an optimization, not the source of truth. If Redis disappears and the database still holds the authoritative data, &lt;code&gt;Application → Database&lt;/code&gt; remains a valid path even with Redis gone. That's the theory. The practice is harder: what happens when 100,000 requests/sec were relying on Redis catching most of them, and it suddenly can't?&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The cache failure avalanche
&lt;/h2&gt;

&lt;p&gt;Under normal conditions, 100,000 requests/sec against a 95% hit ratio means the database sees roughly 5,000/sec — comfortable. The instant Redis fails, all 100,000 requests fall through, because there's no cache left to catch any of them. That's not a proportional increase. It's the full, unfiltered load the cache was built specifically to prevent, arriving all at once.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Why the database can take the whole system down with it
&lt;/h2&gt;

&lt;p&gt;If the database comfortably handles 10,000 requests/sec but Redis was normally absorbing enough that the database only saw a fraction of 100,000 total traffic, a sudden 100,000-request flood is 10x what the database can actually process. From there the chain writes itself: the database slows down, application requests queue, connection pools fill, threads wait, timeouts rise, clients retry, and the retries add more load on top of an already-struggling system. This is a cascading failure — the cache didn't just fail, its failure propagated.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Retries make this worse, not better
&lt;/h2&gt;

&lt;p&gt;A request that times out against Redis and retries twice more has turned one logical request into three actual ones. Multiply that across 100,000 requests and you've turned 100,000 requests into 300,000 — against a dependency that was already failing before the retries even started. Retries sound protective. Against an already-overloaded system, they're an amplifier.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Timeouts decide how long the damage takes to start
&lt;/h2&gt;

&lt;p&gt;A 10-second Redis timeout sounds conservative, but it means 100,000 requests can all be waiting on Redis simultaneously, holding threads and connections the whole time. A short timeout — 50ms, not 10 seconds — lets a request fail fast and move to its fallback instead of sitting there. The principle holds regardless of the exact number: a dependency that's already failing shouldn't be allowed to hold your application hostage while it does.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Fail open vs. fail closed
&lt;/h2&gt;

&lt;p&gt;When Redis is unavailable, should the application keep going without it, or refuse the operation entirely? Both are legitimate answers, and which one is correct depends entirely on what the cache was actually doing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fadddbde8lnxypjwa61ln.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fadddbde8lnxypjwa61ln.png" alt="Fail open or fail closed, depending on what the cache is for: for a product description, Redis being unavailable means skipping the cache and serving the database directly, since a slower response still works; for rate limiting, Redis being unavailable means the limit can't be verified at all, so the safer choice is rejecting or restricting the request rather than silently allowing everything through" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A product description can fail open — skip the cache, hit the database, the user gets a slightly slower but correct response. Rate limiting is the opposite case: if you can't verify whether a user has exceeded their limit, silently allowing every request through removes a protection you were relying on. Failing closed there — rejecting or restricting the request — is the safer default. Neither choice is universally right; the purpose behind the cache is what decides.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Cache as optimization vs. cache as infrastructure
&lt;/h2&gt;

&lt;p&gt;This distinction matters more than it first appears. When Redis is purely making reads faster, its disappearance still leaves &lt;code&gt;Application → Database&lt;/code&gt; intact. But Redis is often doing more than caching — sessions, rate limiting, distributed locks, queues, coordination. At that point it's not a cache anymore, it's infrastructure, and its failure needs to be handled with the seriousness of losing infrastructure, not the shrug you'd give a slow cache. Don't assume every Redis dependency has a database fallback waiting behind it — some of them don't have one at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Circuit breakers
&lt;/h2&gt;

&lt;p&gt;Sending every one of 10,000 failing requests to a Redis that's already down accomplishes nothing except adding load to something already struggling. A circuit breaker recognizes the failure pattern and stops sending traffic to Redis at all, routing everything straight to the fallback instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnglrkgywse20pmt0opp7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnglrkgywse20pmt0opp7.png" alt="The circuit breaker's three states: CLOSED sends requests to Redis normally until failures cross a threshold, which opens the circuit and stops calling Redis entirely; after a cooldown, HALF-OPEN allows one test request through — success returns the circuit to CLOSED, failure sends it back to OPEN" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;CLOSED is business as usual. OPEN means Redis isn't contacted at all — every request goes straight to its fallback. HALF-OPEN is the recovery test: one request is allowed through after a cooldown period, and its outcome decides whether the circuit reopens fully or closes back to normal. The point of the whole mechanism isn't detecting that Redis failed — it's not re-detecting the same failure 100,000 times a second while Redis is trying to recover.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Graceful degradation
&lt;/h2&gt;

&lt;p&gt;The system doesn't need to be perfect. It needs to remain useful. If Redis goes down and an e-commerce homepage normally shows product info, search, checkout, recommendations, and personalized offers, taking down the entire page because recommendations can't load is a self-inflicted wound. Product information, search, and checkout can stay up; recommendations and personalized offers can show a fallback or simply not render. The user still has a working site.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. Sorting data into critical, important, and optional
&lt;/h2&gt;

&lt;p&gt;This classification should directly shape your fallback strategy. &lt;strong&gt;Critical&lt;/strong&gt; data — authorization, payment validation, certain security controls — means the operation genuinely cannot proceed safely without it. &lt;strong&gt;Important&lt;/strong&gt; data — recommendations, personalization, analytics — means the operation continues with reduced functionality. &lt;strong&gt;Optional&lt;/strong&gt; data — a trending widget, recently viewed items, decorative content — the user may not even notice its absence. Knowing which bucket a piece of data falls into, before the incident happens, is what makes graceful degradation possible instead of theoretical.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Serving stale data on purpose during an outage
&lt;/h2&gt;

&lt;p&gt;If a local, slightly-old copy exists, serving it can beat going all the way to the database. Trending products that are 10 minutes stale — the user probably never notices. An account balance served 10 minutes stale is a completely different kind of problem. The decision about which data gets this treatment during an outage is the same staleness-tolerance question from Part 13, just applied under failure conditions instead of normal ones.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. Cache miss and cache failure are not the same thing
&lt;/h2&gt;

&lt;p&gt;A miss means the cache is working correctly and simply doesn't have this key — &lt;code&gt;GET product:123&lt;/code&gt; returning &lt;code&gt;nil&lt;/code&gt; is expected, routine behavior. A failure means you couldn't reliably talk to the cache at all — a connection timeout is not the same category of event, and treating it identically to a miss hides a real problem. A miss needs a database query. A failure may need a fallback, a circuit breaker trip, an alert, and traffic protection — a much bigger response than "just go to the database this once."&lt;/p&gt;




&lt;h2&gt;
  
  
  13. Don't keep trying to write to a cache that's already down
&lt;/h2&gt;

&lt;p&gt;Here's a subtle waste: Redis is unhealthy, the application correctly falls back to the database, and then still tries &lt;code&gt;Redis SET&lt;/code&gt; to repopulate the cache — which also fails, on every single request, for no benefit. Once the system knows Redis is unhealthy, it can stop attempting cache writes entirely until the circuit breaker's test request confirms Redis is actually back.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. Protecting the database is the actual priority during an outage
&lt;/h2&gt;

&lt;p&gt;Connection pool limits, concurrency limits, request queues, rate limiting, circuit breakers, load shedding, and fallback responses all exist to keep the database from being the thing that turns a cache incident into a full outage. Deliberately capping how many requests reach the database — say, 10,000 out of 100,000 — and returning a controlled response to the rest sounds harsh compared to "let everything through." But 10,000 succeeding while 90,000 get a controlled failure is a materially better outcome than all 100,000 requests taking down the database and leaving zero requests succeeding.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. Load shedding is a deliberate design choice, not a failure
&lt;/h2&gt;

&lt;p&gt;If database capacity is 10,000 req/sec and incoming traffic is 50,000, deliberately routing 10,000 through and rejecting or deferring the other 40,000 is what keeps the system available at all. A resilient architecture treats "no" as a legitimate, planned response — not a symptom of something broken.&lt;/p&gt;




&lt;h2&gt;
  
  
  16. Recovery can recreate the exact problem you just survived
&lt;/h2&gt;

&lt;p&gt;Redis comes back online with an empty cache, and every application instance immediately tries to refill it — which, at scale, is just the original stampede happening again, this time triggered by recovery instead of failure. Cache warming, request coalescing, jittered expiration, and rate-limited rebuilding — the Part 7 stampede defenses — apply just as much to the moment a cache comes back as to the moment a popular key expires.&lt;/p&gt;




&lt;h2&gt;
  
  
  17. Redis Cluster failure and its own subtleties
&lt;/h2&gt;

&lt;p&gt;Replication lets a cluster fail over from a failed primary to a replica, reducing downtime — but failover isn't instantaneous or invisible. There can be a failover delay, transient connection errors, temporarily unavailable keys, and replication lag even after the new primary takes over. Applications still need to handle these as real, if brief, failure conditions rather than assuming clustering makes failure disappear.&lt;/p&gt;




&lt;h2&gt;
  
  
  18. Network failure can look exactly like Redis failure
&lt;/h2&gt;

&lt;p&gt;Redis can be perfectly healthy while your application simply can't reach it — a network partition looks identical to a dead Redis from the application's point of view. This is why monitoring Redis server health alone isn't sufficient: &lt;code&gt;Redis server health: GOOD&lt;/code&gt; and &lt;code&gt;Application-observed Redis latency: BAD&lt;/code&gt; can both be true simultaneously, and only the application's own perspective catches the second one.&lt;/p&gt;




&lt;h2&gt;
  
  
  19. Run the capacity math before an incident forces you to
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fygw4gxo5ehyu5ixso5ur.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fygw4gxo5ehyu5ixso5ur.png" alt="Run the math before the outage does it for you: at 50,000 requests per second with a 98% hit ratio, the database normally sees about 1,000 requests per second, comfortably under its 5,000 request-per-second capacity — but if Redis fails, all 50,000 requests per second fall through at once, ten times more than the database can actually hold" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At 50,000 requests/sec and a 98% hit ratio, the database normally sees roughly 1,000/sec — well within a 5,000/sec capacity. If Redis fails, the database faces the full 50,000/sec: 50 times its normal load, and 10 times more than it can actually process. This is exactly the kind of calculation that belongs in an architecture review, done deliberately with real numbers, rather than discovered for the first time during the incident itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  20. What an incident actually looks like end to end
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu7apyizl35v2cl5rdiqv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu7apyizl35v2cl5rdiqv.png" alt="The dashboard that shows the whole chain at once: cache hit ratio collapsing from 96% to 0%, Redis latency going from 2ms to timeout, database QPS rising from 4,000 to 80,000, database CPU rising from 35% to 99%, and application latency rising from 80ms to 4,000ms — five metrics telling one connected story" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Watched together rather than separately, these five numbers tell a single causal story: cache hit ratio collapses, database traffic spikes in direct response, CPU saturates under that load, and application latency follows immediately after. Knowing "Redis is at 80% CPU" in isolation tells you far less than seeing this whole chain — which is why cache miss rate correlated against database QPS is one of the most valuable signals a caching system can expose.&lt;/p&gt;




&lt;h2&gt;
  
  
  21. Don't retry everything, indefinitely
&lt;/h2&gt;

&lt;p&gt;The failure mode to avoid: retry, fail, retry, fail, retry, fail, with no bound and no growing delay. The alternative is bounded retries, short timeouts, exponential backoff, jitter on that backoff, and a circuit breaker that eventually stops the attempts altogether. The goal was never "keep trying until it works" — it's "give the dependency one reasonable chance without piling onto its failure or ours."&lt;/p&gt;




&lt;h2&gt;
  
  
  22. Cache failure needs to actually be tested
&lt;/h2&gt;

&lt;p&gt;Most test suites, per Part 10, cover the cache working. Far fewer cover it not working: Redis unavailable, Redis slow, connection exhaustion, a node failing outright, elevated network latency, an empty cache, mass expiration, a slow database, an unavailable database. The question every one of those scenarios needs answered isn't "does this pass" — it's "does the application remain useful."&lt;/p&gt;




&lt;h2&gt;
  
  
  23. Chaos testing, for systems that can support it
&lt;/h2&gt;

&lt;p&gt;Mature systems can go further and deliberately inject a Redis failure in a controlled environment, then observe: does traffic actually move to the database, does the database survive it, do circuit breakers open when they should, do alerts fire, does recovery behave the way it's supposed to. The goal was never "prove nothing fails" — that's not a realistic goal for any real system. It's "know exactly how the system behaves when something does," ahead of the moment you need that knowledge.&lt;/p&gt;




&lt;h2&gt;
  
  
  The golden rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never assume a cache outage is harmless just because the cache isn't your source of truth.&lt;/strong&gt; The cache may be absorbing 95%, 98%, or more of your total traffic — remove it suddenly, and the database inherits all of it at once. The question to ask when designing any cache is "what happens if this disappears right now" — and the answer should come from a calculation, not a guess.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bigger lesson
&lt;/h2&gt;

&lt;p&gt;Caching gets introduced to make a system faster. Once a system becomes dependent on that cache, caching also becomes a resilience question — and the failure chain (cache failure → misses → database overload → application slowdown → timeouts → retries → more load → cascading failure) is entirely predictable in shape, even if the trigger varies.&lt;/p&gt;

&lt;p&gt;A resilient system breaks that chain with timeouts, circuit breakers, fallbacks, concurrency limits, load shedding, graceful degradation, cache warming, and real observability — not as separate features bolted on, but as one coordinated resilience strategy. The goal was never making the cache impossible to fail. That's not achievable. The goal is making the application capable of surviving the failure when it happens — and that distinction is what separates a caching implementation from a production-grade caching architecture.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Has your system been through a real cache outage — and looking back, was the actual failure mode the one you'd already planned for, or something the runbook hadn't considered?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>resilience</category>
      <category>systemdesign</category>
      <category>redis</category>
    </item>
    <item>
      <title>Real-World Caching Patterns: Choosing the Right Strategy</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Wed, 26 Aug 2026 02:51:21 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/real-world-caching-patterns-choosing-the-right-strategy-5d4j</link>
      <guid>https://dev.to/muralidharan_lakshmanan/real-world-caching-patterns-choosing-the-right-strategy-5d4j</guid>
      <description>&lt;p&gt;This series has covered cache-aside, read-through and write-through, TTL, eviction, invalidation, stampedes, distributed caching, Redis, local caching, CDNs, edge caching, and scaling. But the question that actually matters when you sit down to design a system is simpler than any of that: &lt;strong&gt;what should I cache, where should I cache it, how long should I keep it, and what happens if the cached value is wrong?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There's no single answer. Let's walk through real scenarios and see how the answer changes each time.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. E-commerce product catalog
&lt;/h2&gt;

&lt;p&gt;A store with 10 million products, all getting hit with &lt;code&gt;GET /products/12345&lt;/code&gt; repeatedly. Thousands of users requesting the same product is exactly the shape caching exists for — one database query can serve all of them through Redis.&lt;/p&gt;

&lt;p&gt;But should every field on that product be cached the same way? A description and an image rarely change; a rating changes periodically; a price can change often; inventory changes constantly. Treating all of them identically wastes the opportunity that caching offers — the strategy should follow each field's actual volatility, not a single TTL applied to the whole object.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Inventory is a different animal entirely
&lt;/h2&gt;

&lt;p&gt;"Only 1 left!" and two customers clicking "buy" at nearly the same instant is exactly the scenario where a cached inventory count can sell something that's no longer there. That's a materially different kind of wrong than a stale product description — one is cosmetic, the other loses money and trust. A common answer: cache the product information normally, but keep inventory tied to an authoritative source, or use a very short-lived cache combined with an authoritative check at the actual moment of checkout. Not all data on the same object deserves the same consistency guarantee.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Banking customer profile
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GET /customer/profile&lt;/code&gt; doesn't change often, which makes it look cacheable — but it's private, personalized, and potentially sensitive, so it can never casually land in a shared CDN cache the way a product image could. A private cache keyed by identity — &lt;code&gt;customer:12345:profile&lt;/code&gt; — keeps one customer's data from ever being served to another. This is the Part 12 public/private distinction showing up with real stakes attached.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Account balance is where caching should back off
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GET /account/balance&lt;/code&gt; is the sharper version of the same problem. A stale product description is mildly annoying. A stale balance showing $10,500 when the real number is $10,000 is a different category of wrong entirely. This is often a case for going straight to an authoritative service rather than aggressively caching — caching isn't automatically good just because something is read frequently. Correctness comes first, and for this kind of data, it isn't a close call.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Social media feed — precompute instead of cache-on-request
&lt;/h2&gt;

&lt;p&gt;A feed is expensive to generate, requested constantly, personalized, and constantly changing — a genuinely hard caching problem, because the usual "compute once, serve many times" logic doesn't hold when the result is different for every user. The answer often isn't caching after the fact — it's precomputing before anyone asks: a new post triggers fan-out processing that updates a feed cache directly, so a user's request reads an already-assembled result instead of triggering a live rebuild.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The precompute pattern, generalized
&lt;/h2&gt;

&lt;p&gt;The general version of that idea: instead of "store the result after someone asks for it," it's "calculate the result before anyone asks." Trending products recalculated every five minutes and written to Redis means 100,000 users read the same precomputed value instead of triggering the computation 100,000 times. This pattern earns its keep for leaderboards, trending content, recommendations, reports, dashboards, and aggregated statistics — anywhere the expensive part can happen once, on a schedule, ahead of demand.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Search results — repetition matters more than volume
&lt;/h2&gt;

&lt;p&gt;Search is expensive, but many users searching "wireless headphones" means genuine repetition exists to exploit — cache the result, and check the cache before hitting the search engine on a miss.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Cache key explosion is the trap hiding behind that idea
&lt;/h2&gt;

&lt;p&gt;Search results depend on query, filters, sorting, pagination, and sometimes location or personalization — combine enough of those dimensions and the number of distinct cache keys can explode into millions of low-value entries that are each requested once and never again. Before caching search results, it's worth actually asking how often the &lt;em&gt;exact&lt;/em&gt; same query repeats. If nearly every query is unique, caching provides close to nothing — the infrastructure cost without the benefit.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Configuration — one of the best caching candidates that exists
&lt;/h2&gt;

&lt;p&gt;Feature flags, application settings, supported countries, UI configuration: read constantly, changed rarely. This is about as clean a caching candidate as exists, and it's worth loading at application startup directly into memory rather than hitting Redis on every request — thousands of requests can then be served from a local copy without a network call at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. But configuration has a real invalidation problem
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;enableNewPaymentFlow&lt;/code&gt; flips from &lt;code&gt;false&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; and application instances refresh their local copies at different times, some instances run the new flow while others run the old one simultaneously — genuinely inconsistent behavior across a fleet that's supposedly running the same code. Event-driven refresh, the pattern from Part 5, is the common fix: a configuration-changed event fans out to every instance, and each one refreshes its local cache on receipt rather than waiting for its own independent TTL to expire.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Session data
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;session:abc123&lt;/code&gt; holding user ID, login state, preferences, and expiration is the classic reason to keep sessions in a shared store like Redis rather than pinned to one application server — any instance behind the load balancer can serve any user's session, which is what makes horizontal scaling behind a load balancer actually work cleanly.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. API response caching — sometimes the CDN is simply the right layer
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GET /api/countries&lt;/code&gt; changes once every few weeks but gets hit constantly. Public, shared, relatively static, and naturally an HTTP response — that's the CDN's exact sweet spot from Part 12, and routing this through Redis instead would be solving a problem the CDN already solves for free.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. Images and static content — the easiest call in this entire post
&lt;/h2&gt;

&lt;p&gt;Images, CSS, JavaScript, fonts, video: a CDN is the obvious answer, and the application doesn't need to participate in serving any of it. This is a large part of how modern web architectures serve enormous traffic volumes without correspondingly enormous application-server fleets.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. Expensive computation, not just expensive database reads
&lt;/h2&gt;

&lt;p&gt;Caching isn't only for database results. If &lt;code&gt;calculateCustomerRisk()&lt;/code&gt; takes 800ms and the underlying inputs only change hourly, caching the &lt;em&gt;result&lt;/em&gt; of the computation — not a database row — is exactly as valid a caching decision. The same logic applies to recommendation scores, ML predictions, pricing calculations, analytics, reports, and complex business rules: anywhere the expensive part is CPU time rather than a database round trip.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. Caching a specific query result, not a whole object
&lt;/h2&gt;

&lt;p&gt;Sometimes what's worth caching isn't a business object at all but one specific expensive query — &lt;code&gt;SELECT COUNT(*) FROM orders WHERE customer_id = 12345&lt;/code&gt; cached as &lt;code&gt;customer:12345:order-count&lt;/code&gt;. The invalidation question shows up immediately: every new order changes the answer, so either invalidate on order creation or deliberately accept some staleness. Which one is right depends entirely on how much that count is allowed to lag reality.&lt;/p&gt;




&lt;h2&gt;
  
  
  16. Rate limiting isn't caching, but it's the same muscle
&lt;/h2&gt;

&lt;p&gt;Tracking &lt;code&gt;user:12345:requests&lt;/code&gt; against a 100-requests-per-minute limit via a Redis counter isn't "cache the database result" in the traditional sense — there's no database result being cached at all. It's worth including here anyway, because it demonstrates something broader: a fast, shared, in-memory store solves problems well beyond caching once it exists in your architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  17. Feature flags, and designing the fallback deliberately
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;isNewCheckoutEnabled()&lt;/code&gt; checked hundreds of times a second shouldn't hit a remote configuration service on every call — a local cache refreshed periodically gives low latency, resilience, and less network traffic. The question worth asking explicitly: what happens if the cached flag value is wrong? For some flags the safe default is off; for others it's on. That fallback behavior needs to be a deliberate choice per flag, not an accident of whatever the cache happened to return.&lt;/p&gt;




&lt;h2&gt;
  
  
  18. Recommendations tolerate more staleness than they get credit for
&lt;/h2&gt;

&lt;p&gt;A recommendation engine combining user history, the product catalog, an ML model, and business rules can easily take hundreds of milliseconds to compute. Caching &lt;code&gt;recommendations:user:12345&lt;/code&gt; for five minutes (or longer) raises a genuinely useful question: does a recommendation actually need to change every second? Usually not — and once that's acknowledged, a five-minute-old recommendation stops looking like a compromise and starts looking like the obviously correct design.&lt;/p&gt;




&lt;h2&gt;
  
  
  19. Stock market data — one domain, four completely different strategies
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwg98mju8o49viqrjqvu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgwg98mju8o49viqrjqvu.png" alt="One stock ticker, four different caching strategies: the live price goes through a streaming system rather than a cache at all, historical price is cached since it's fixed once recorded, company info sits in a long-lived cache since it rarely changes, and news gets a short-lived cache measured in minutes" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AAPL&lt;/code&gt; isn't one caching decision — it's at least four. The live price genuinely shouldn't be cached at all; it belongs in a streaming system built for continuous updates. Historical prices are effectively immutable once recorded, so they cache indefinitely. Company information changes maybe once a year and can sit in a long-lived cache. News needs a short TTL measured in minutes, not hours. Same ticker symbol, four different answers to "how should this be cached" — because the question was never really about the ticker, it was about each piece of data behind it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The real question, restated
&lt;/h2&gt;

&lt;p&gt;Every scenario above resolves to the same underlying question, and it's worth seeing them plotted against it directly rather than as a list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbawrbjwfpmq5af4ymt2m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbawrbjwfpmq5af4ymt2m.png" alt="The question that matters isn't how often something is read, it's what happens if the answer served is wrong: images, CSS, and JS sit at the safely-stale end; product descriptions, recommendations, and search results sit in the middle; product price, session data, inventory, and account balance sit toward the dangerous end where staleness has real consequences" width="800" height="569"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Access frequency tells you whether caching &lt;em&gt;would help&lt;/em&gt;. Staleness tolerance tells you whether it's &lt;em&gt;safe&lt;/em&gt;. Those are different questions, and a data type can score high on the first and low on the second — account balances are read constantly, which says nothing about how safe they are to cache.&lt;/p&gt;




&lt;h2&gt;
  
  
  A framework for the decision itself
&lt;/h2&gt;

&lt;p&gt;Before caching anything, it's worth running through this in order:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd709v6ikholjmighhjkk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd709v6ikholjmighhjkk.png" alt="Should this be cached at all: is it frequently read, and if so is it expensive to retrieve, and if so can it tolerate staleness — a " width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A "no" on being frequently read, or a "no" on being expensive to retrieve, just means caching wouldn't help much — a mild missed opportunity at worst. A "no" on tolerating staleness is a different kind of answer entirely: it means caching could make the system actively wrong, not just occasionally slow, and the better move is going to the authoritative source directly rather than trying to cache carefully around the problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Don't forget the failure path
&lt;/h2&gt;

&lt;p&gt;Every design above has a HIT path and a MISS path. Production also needs a defined answer for what happens when the cache itself is unavailable — &lt;code&gt;Redis ERROR&lt;/code&gt; needs its own branch, not an assumption that it collapses into MISS behavior automatically. We'll go deeper on this specific failure mode next.&lt;/p&gt;




&lt;h2&gt;
  
  
  One application, many strategies at once
&lt;/h2&gt;

&lt;p&gt;The biggest practical lesson across every scenario above: there's no requirement to standardize on one mechanism company-wide.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvl1j6yrwrbqxiqvyxr9q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvl1j6yrwrbqxiqvyxr9q.png" alt="One application doesn't have to pick one caching mechanism: feature flags and recommendations live in a local cache, sessions and products live in Redis, and images and static API responses live in a CDN — different data, different requirements, different mechanism, all inside the same system" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Different data has different requirements, and using a different mechanism for each is completely normal — not a sign the architecture is inconsistent, but a sign it's actually matched to the problem in each case.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architect's actual starting question
&lt;/h2&gt;

&lt;p&gt;Not "should we use Redis?" — that question skips past everything that determines the right answer. The better starting point: &lt;strong&gt;which requests are expensive, repetitive, and safe to serve from a cached representation?&lt;/strong&gt; From there: what's the data, how often is it read, how often does it change, how stale can it safely be, who's allowed to see it, where should it live, how does it get invalidated, and what happens when the cache fails? That sequence, worked through deliberately, is a far stronger design process than picking a technology first and looking for places to apply it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bigger lesson
&lt;/h2&gt;

&lt;p&gt;A product catalog, a bank balance, an image, a session, a recommendation, a search result, and a stock price can all legitimately need completely different caching approaches — and that's not a failure to standardize, it's the correct outcome of actually looking at each one. The question was never "can I cache this?" It's &lt;strong&gt;"what happens if I serve an old value?"&lt;/strong&gt; If the honest answer is "nothing important," that's very likely a great caching candidate. If the honest answer is "we could lose money, expose private data, or make an incorrect business decision," caching needs far more careful design — or, in some cases, shouldn't be used there at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Which of these scenarios hits closest to something you've actually built — and did the staleness question get asked before or after something went wrong in production?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>systemdesign</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>Caching Beyond Redis: CDN, HTTP Cache &amp; Edge Caching</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Tue, 25 Aug 2026 02:29:40 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/caching-beyond-redis-cdn-http-cache-edge-caching-164m</link>
      <guid>https://dev.to/muralidharan_lakshmanan/caching-beyond-redis-cdn-http-cache-edge-caching-164m</guid>
      <description>&lt;p&gt;Most of this series has lived inside one architecture: user, application, Redis, database. Redis is genuinely good at keeping database load down. But there's a question worth asking that Redis can't answer: what if the request never reached Redis either? Or further still — what if it never reached the application at all?&lt;/p&gt;

&lt;p&gt;That's HTTP caching, browser caching, CDNs, and edge caching — and they lead to one of the most useful ideas in performance engineering: &lt;strong&gt;the fastest request is the one you never have to process.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Browser caching
&lt;/h2&gt;

&lt;p&gt;Start at the very edge: the user's own browser. A site's &lt;code&gt;logo.png&lt;/code&gt;, &lt;code&gt;styles.css&lt;/code&gt;, and &lt;code&gt;app.js&lt;/code&gt; don't need to be downloaded on every single visit. The browser can keep a local copy, and the next request becomes a local cache hit — no network request, no CDN request, no application request, no database request. It's about as cheap as a cache hit gets.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. How the browser knows what to cache
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Cache-Control&lt;/code&gt; headers make the decision. &lt;code&gt;Cache-Control: max-age=3600&lt;/code&gt; tells the browser the response is fresh for an hour. For static content that rarely changes, something more aggressive is common: &lt;code&gt;Cache-Control: public, max-age=31536000, immutable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That raises an obvious question: if the browser holds onto a file for a year, how does it ever get a new version? Through the filename itself — &lt;code&gt;app.abc123.js&lt;/code&gt; becomes &lt;code&gt;app.def456.js&lt;/code&gt; when the content changes, the browser sees a different URL, and it downloads the new file without needing to be told the old one expired. This is cache busting, and it's the foundation for a technique we'll come back to.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Cache-Control decides where a response is allowed to live, not just for how long
&lt;/h2&gt;

&lt;p&gt;This is easy to undersell. &lt;code&gt;Cache-Control: public, max-age=3600&lt;/code&gt; means shared caches like CDNs are allowed to store the response for everyone. &lt;code&gt;Cache-Control: private, max-age=3600&lt;/code&gt; means only the individual browser may keep it — a CDN sitting in between must not. &lt;code&gt;Cache-Control: no-store&lt;/code&gt; means don't keep this anywhere, period.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhtvpq25jgiq3n6vh5mrk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhtvpq25jgiq3n6vh5mrk.png" alt="One header decides where a response is allowed to live: public responses can be cached by the browser and shared caches like CDNs, private responses only by the browser, and no-store responses must never be cached anywhere — personalized and sensitive data always belongs in that third column" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This distinction is a security boundary, not just a performance knob. Getting it backwards — marking personalized data &lt;code&gt;public&lt;/code&gt; by accident — doesn't just cause staleness. It can mean one user's cache serves another user's data.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. What actually belongs in the browser
&lt;/h2&gt;

&lt;p&gt;Good candidates: JavaScript, CSS, images, fonts, static HTML, public assets, and some genuinely public API responses — a public product catalog, public configuration, public content. Bad candidates: account information, private messages, financial details, anything personalized, anything authentication-related. The browser isn't only a performance layer — it's a data boundary, and "it's just cached" is never a reason to assume something is safe to cache.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Enter the CDN
&lt;/h2&gt;

&lt;p&gt;If the application lives in Virginia and a user is in Tokyo, every request without a CDN makes that full round trip. With a CDN, the Tokyo user hits a nearby edge location instead, and the response comes from far closer to home. A &lt;strong&gt;Content Delivery Network&lt;/strong&gt; maintains many geographically distributed edge locations — sometimes called points of presence, or PoPs — each holding copies of content that originates from one authoritative source.&lt;/p&gt;

&lt;p&gt;The CDN cache-hit and cache-miss shape is identical to the Redis pattern from earlier in this series, just relocated: a hit returns the cached resource without touching the origin; a miss fetches from the origin, returns it, and typically caches it there for the next request.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The layered picture, and what it actually saves
&lt;/h2&gt;

&lt;p&gt;Put browser, CDN, application, Redis, and database in one request path, and every layer that answers stops the request from going any deeper.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0dnzksk7qd2i0f94oaot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0dnzksk7qd2i0f94oaot.png" alt="100,000 requests in, 1,000 reach the database: 30,000 are absorbed by the browser cache before they leave the device, 50,000 more are absorbed by the CDN before they reach any infrastructure, 19,000 are absorbed by Redis before the database is touched, and only 1,000 — 1% of the original traffic — actually reach the database" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's not a hypothetical ratio — it's the realistic shape of a well-cached system under real traffic. Each layer isn't competing with the others; it's catching what the layer before it missed.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. ETags and conditional requests
&lt;/h2&gt;

&lt;p&gt;Here's a different mechanism worth knowing well: the browser already has &lt;code&gt;product.json&lt;/code&gt;, but instead of re-downloading it wholesale on every request, the server can hand back an identifier for the current version — &lt;code&gt;ETag: "abc123"&lt;/code&gt;. The next time the browser asks for that resource, it includes &lt;code&gt;If-None-Match: "abc123"&lt;/code&gt;. If nothing changed, the server replies &lt;code&gt;304 Not Modified&lt;/code&gt; and sends no body at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08nj4urzvx6jhckag85b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F08nj4urzvx6jhckag85b.png" alt="Why a 304 matters: a client fetches a 2MB response and stores its ETag, and on the next request sends that ETag back — if the content hasn't changed, the server replies 304 Not Modified with no body, sparing the client and server the full payload transfer" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a large response, this is a real saving — bandwidth, transfer time, and server-side work, all avoided on a request that still technically happened but never had to pay for the expensive part.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. CDN and Redis solve related but different problems
&lt;/h2&gt;

&lt;p&gt;Redis lives close to the application and excels at application data, sessions, computed results, and frequently accessed database records. A CDN lives close to the user and excels at images, JavaScript, CSS, video, static files, and cacheable public HTTP responses. Neither replaces the other — most real systems that need both use both, each doing the job it's actually good at.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Edge caching isn't only for static files anymore
&lt;/h2&gt;

&lt;p&gt;The instinct is "CDN = static assets," but modern edge platforms can cache dynamic HTTP responses too. If &lt;code&gt;GET /products/popular&lt;/code&gt; returns an identical response for a large share of users, caching that response at the edge turns 100,000 application requests into a small number of them — occasionally, on a miss, one request reaches the application, Redis, and the database, and the CDN caches the result for everyone else. This can change the entire scalability profile of an API without touching the backend at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. Personalized data breaks the pattern completely
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GET /account/profile&lt;/code&gt; cannot be cached and served identically to everyone — Alice's balance is not Bob's balance, and caching that response globally means Alice eventually sees Bob's data. Every caching decision needs to ask: is this response public, personalized, or sensitive, and can it tolerate staleness at all? Getting this wrong isn't a performance bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Cache keys and the Vary header
&lt;/h2&gt;

&lt;p&gt;For shared HTTP caches, what makes two requests "the same" is genuinely more subtle than the URL alone. &lt;code&gt;GET /products?category=laptops&lt;/code&gt; and &lt;code&gt;GET /products?category=phones&lt;/code&gt; need separate cache entries even though they hit the same endpoint. Headers can matter too — a response might legitimately differ for &lt;code&gt;Accept-Language: en&lt;/code&gt; versus &lt;code&gt;Accept-Language: fr&lt;/code&gt;. The &lt;code&gt;Vary&lt;/code&gt; header tells caches this explicitly: &lt;code&gt;Vary: Accept-Language&lt;/code&gt; means the cache needs to keep a separate representation per language rather than assuming one response fits everyone.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. Invalidation shows up here too — and gets solved cleanly
&lt;/h2&gt;

&lt;p&gt;Part 5 called invalidation the hardest problem in caching, and it doesn't get easier once a resource is cached in a browser, three CDN regions, and who knows where else. Waiting for TTL works but is slow. Explicitly purging every CDN edge works but requires reaching every single one. There's a third option that sidesteps the problem almost entirely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj54d9qc6feadsxj40qcp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj54d9qc6feadsxj40qcp.png" alt="Invalidation by renaming instead of asking: purge-based invalidation requires telling every cache layer to forget the old file, and missing even one means someone keeps seeing stale content; a versioned URL means the new content simply gets a new filename, so every cache correctly misses once on the new URL while old caches holding the old URL are simply never referenced again" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app.8f31c2.js&lt;/code&gt; becomes &lt;code&gt;app.a91d22.js&lt;/code&gt; when the content changes. Nobody has to tell any cache anywhere to forget the old file — the old URL just stops being referenced, and its cached copies become permanently irrelevant rather than dangerously stale. This is one of the cleanest invalidation techniques that exists, and the same trick applies to Redis keys directly: &lt;code&gt;product:v1:101&lt;/code&gt; becoming &lt;code&gt;product:v2:101&lt;/code&gt; sidesteps needing to invalidate every old representation, the versioning technique from Part 9.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. Stale-while-revalidate, at the HTTP layer
&lt;/h2&gt;

&lt;p&gt;We covered this pattern in Part 5 and Part 7 as a stampede defense; HTTP has a first-class way to express it directly: &lt;code&gt;Cache-Control: max-age=60, stale-while-revalidate=300&lt;/code&gt;. For the first 60 seconds the response is fresh. For the next 300 seconds after that, a cache may serve the stale value immediately while refreshing it in the background. If generating a response normally costs 500ms, the user never pays that cost directly — they get the existing value immediately while the refresh happens behind them. This only works where staleness is genuinely acceptable: product recommendations, probably fine; an account balance, not even close.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. What edge caching costs you
&lt;/h2&gt;

&lt;p&gt;None of this is free. Freshness suffers by definition — users can see slightly old data. Invalidation needs a real strategy, whether that's purging or the versioning technique above. Query parameters and headers can fragment one logical resource into many cache entries. Personalized responses must never leak into a shared cache. And debugging gets genuinely harder — a response might have passed through a browser cache, a CDN, an API gateway, the application, and Redis before you ever see it, and figuring out which layer actually served a given response becomes its own skill.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;X-Cache: HIT&lt;/code&gt; or &lt;code&gt;X-Cache: MISS&lt;/code&gt; headers, which most CDNs expose in some form, are what make that debugging tractable — being able to see "Browser: MISS, CDN: HIT" versus "Browser: MISS, CDN: MISS, Redis: HIT" turns a guessing game into a quick trace.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. A practical decision framework
&lt;/h2&gt;

&lt;p&gt;Before deciding where something belongs, it's worth running through a short list of questions: Is the data static, or close to it? Is it public and genuinely shared across users, or personalized? Is it application data that fits naturally in Redis? Is it hot enough to justify a local cache? Can it tolerate being briefly stale? How often does it actually change? How expensive is it to generate in the first place? The answers point toward a layer — browser, CDN, Redis, database, or in some cases, deliberately, nowhere at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bigger lesson
&lt;/h2&gt;

&lt;p&gt;The goal was never "cache everything, everywhere" — that's how systems become fragile and hard to reason about. The goal is the minimum work required to produce a correct response, and sometimes that minimum genuinely is "don't cache this." Caching, across this whole series, has really been one continuous idea at different distances from the user: CPU cache, memory, a local application cache, Redis, a CDN, the origin, the database — different costs, different consistency guarantees, different failure modes, same underlying question of how close a copy of the truth needs to sit to where it's actually needed.&lt;/p&gt;

&lt;p&gt;A high-performance system doesn't necessarily need a bigger database cluster. Sometimes the biggest win is simply making sure most requests never reach the database — and sometimes, further still, making sure they never reach the application at all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Which layer in your own stack is doing the most invisible work right now — browser cache, CDN, or something further back — and would you actually know if it stopped?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>cdn</category>
      <category>http</category>
      <category>webperf</category>
    </item>
    <item>
      <title>Caching at Scale: When Millions of Requests Hit Your System</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Mon, 24 Aug 2026 03:23:37 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/caching-at-scale-when-millions-of-requests-hit-your-system-1ha8</link>
      <guid>https://dev.to/muralidharan_lakshmanan/caching-at-scale-when-millions-of-requests-hit-your-system-1ha8</guid>
      <description>&lt;p&gt;Every architecture in this series so far has had roughly the same shape: application, Redis, database. That works beautifully for a lot of real systems. But traffic grows — 1,000 requests/sec becomes 10,000, then 100,000, then over a million — and at some point the question stops being &lt;em&gt;"should we use a cache?"&lt;/em&gt; and becomes &lt;strong&gt;"how do we make the cache itself scale?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's a genuinely different problem, and it's what this post is about.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Scaling changes the question
&lt;/h2&gt;

&lt;p&gt;The simple picture — application, Redis, database — assumes roughly one application talking to one Redis. Once there are hundreds of application instances behind a load balancer, all of them are hitting the same Redis, and Redis itself becomes the scalability boundary the rest of the system depends on. It stops being "the fast layer" and starts being "the thing everything else is now downstream of."&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Can one Redis node handle it?
&lt;/h2&gt;

&lt;p&gt;Sometimes, yes — a single modern Redis instance handles a lot, especially with simple operations. But it runs into real limits eventually: CPU, memory, network bandwidth, connection count, command throughput, dataset size. A Redis instance sitting at 90% memory, 85% CPU, and 80% network isn't going to absorb indefinitely more traffic just because it's "just a cache." At some point the workload needs to be distributed rather than pushed harder into one place.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Redis Cluster and sharding
&lt;/h2&gt;

&lt;p&gt;The common answer is a Redis Cluster: instead of one node taking 100% of traffic, keys get spread across several nodes. This is sharding — dividing the dataset so no single node holds all of it, and no single node absorbs all the traffic either.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5bju5y8kjvwmtpxvhvj4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5bju5y8kjvwmtpxvhvj4.png" alt="The cache becomes many smaller buckets: each key is hashed once and that hash maps it to a slot number, which determines which of several Redis nodes owns it — product:101 and product:103 don't necessarily share a node just because they're both products" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The mechanism is hashing: &lt;code&gt;hash(key) → slot → node&lt;/code&gt;. The application or Redis client can work out which node owns a given key without needing a central lookup table. The cache stops being one big bucket and becomes a distributed collection of smaller ones — which solves the capacity problem, but introduces a new one.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The hidden problem: hot keys again
&lt;/h2&gt;

&lt;p&gt;Hashing distributes keys evenly, but it says nothing about &lt;em&gt;traffic&lt;/em&gt; being even. If &lt;code&gt;homepage:trending&lt;/code&gt; gets 40% of all requests, hashing still sends every one of those requests to the same single shard — the exact hot-key problem from Part 7, just relocated to a cluster instead of a single instance. One node ends up carrying a wildly disproportionate share of load while the other three sit comfortably under capacity.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Averages hide this
&lt;/h2&gt;

&lt;p&gt;This is the metric trap worth naming explicitly: a cluster-wide average can look completely healthy while one node is already in trouble.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff3kfvas3uhf0x29stcjd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff3kfvas3uhf0x29stcjd.png" alt="The cluster average looked fine, one node wasn't: four Redis nodes report 30%, 35%, 95%, and 30% CPU, averaging to a healthy-looking 45% — but the 95% node is the one about to become the bottleneck, and the average is exactly what hides it" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A 45% cluster average is genuinely meaningless if it's made up of three nodes at 30% and one node quietly sitting at 95%. That fourth node becomes the system's bottleneck long before the aggregate number would ever suggest a problem — which is why per-node metrics, not cluster averages, are what actually catch this in time.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Local caching helps, and complicates things
&lt;/h2&gt;

&lt;p&gt;One response to hot keys: put a local, in-process cache in front of Redis, so an extremely popular value can be served from application memory without a network hop at all. It's dramatically faster — there's no network round trip, which matters more at scale than it sounds like it should.&lt;/p&gt;

&lt;p&gt;But this reintroduces the Part 5 invalidation problem at a larger scale. If four application instances each have their own local copy of &lt;code&gt;product:101&lt;/code&gt; and the product changes, all four copies need to be told, not just Redis. More locality generally buys speed at the cost of more consistency complexity — the same trade-off from Part 8, just compounding as you add layers.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. The multi-level cache, formalized
&lt;/h2&gt;

&lt;p&gt;L1 in application memory, L2 in a Redis cluster, L3 the database — the goal is for most requests to resolve at L1, a smaller share at L2, and very few ever reaching the database. This is the same shape from Part 8's reference architecture, just under enough load that each layer is now load-bearing rather than optional.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Connection pooling becomes an architectural concern
&lt;/h2&gt;

&lt;p&gt;At scale, Redis isn't only a server-side problem. If 500 application instances each open hundreds of Redis connections, that's potentially 100,000+ connections — a real operational number that needs managing through maximum connections, pooling, timeouts, idle-connection handling, and watching for leaks. A cache can be fast on paper while the application performs badly anyway, purely because of how it's managing its connections to that cache.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Network latency and serialization both start to matter
&lt;/h2&gt;

&lt;p&gt;"Redis is in-memory, so it's basically instant" undersells the actual request path: application → network → Redis → network → application. At high request rates those hops add up, which is another reason local caching earns its place. Serialization has the same story — at 500,000 cache requests/sec, the JSON encode/decode on both ends can become a genuinely significant CPU cost. When something feels slow at scale, the bottleneck is often not where "Redis is slow" would suggest — measure application CPU, serialization CPU, network, Redis CPU, and Redis latency separately rather than assuming.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. Replication solves a different problem than sharding
&lt;/h2&gt;

&lt;p&gt;Sharding distributes data. Replication answers a different question: what happens when a node fails? A primary with a replica means a failure doesn't have to mean data loss — but replication isn't free. Writes need to propagate, which costs network traffic, memory, and operational complexity, and replicas can lag behind the primary. For a cache, some lag is often tolerable. Whether it's tolerable for &lt;em&gt;your&lt;/em&gt; cache depends entirely on how much staleness the application can absorb — the same freshness question from Part 2 and Part 5, now with a replication delay added to the TTL.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Multi-region caching, and why it's not a small step
&lt;/h2&gt;

&lt;p&gt;If users are spread across North America, Europe, and Asia, and Redis only lives in North America, every Asian user pays a full round trip to another continent even though the cache itself responds in milliseconds. Regional caching — a cache per region, each close to its users — fixes the latency problem. It does not simplify anything else.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9tl81mcgy7a1bwr8kk8x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9tl81mcgy7a1bwr8kk8x.png" alt="One region's architecture doubled, not simplified: global users split across Region A and Region B, each with its own load balancer, application instances, local L1 cache, and Redis cluster, converging on a shared database layer with a primary and read replicas" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every layer from a single-region architecture now exists per region — and invalidation, which used to be one &lt;code&gt;DELETE&lt;/code&gt; against one Redis, now has to reach every region before a change is actually reflected everywhere. Multi-region caching is a legitimate answer to a real latency problem, but it should be reached for because the latency problem is real and measured, not because global feels more impressive on an architecture diagram.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. Consistency gets structurally harder as each layer is added
&lt;/h2&gt;

&lt;p&gt;At the start, "is the cache correct" was a one-line answer: delete the Redis key after the database updates. Once there's a Redis cluster, local caches on top of it, and multiple regions on top of that, the question stops being "is the cache correct" and becomes "how quickly does a change propagate to every copy, everywhere" — a meaningfully harder problem with no single clean answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. Event-driven invalidation, at this scale
&lt;/h2&gt;

&lt;p&gt;Publishing an event on every data change — &lt;code&gt;Product Updated&lt;/code&gt; fanning out to L1 consumers, Redis in Region A, Redis in Region B — keeps the producer decoupled from needing to know who's listening, the same pattern from Part 5. At this scale, the event system itself becomes something to manage: duplicate events, ordering, retries, delayed delivery, lost events, and idempotency all become real operational concerns rather than edge cases. It's trading one kind of complexity (manual multi-region invalidation) for a different, more distributed one.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. Cache warming, and the deployment-specific version of it
&lt;/h2&gt;

&lt;p&gt;For a known traffic spike — a major sale, a product launch, a sporting event — preloading the keys you already know will be hot avoids a wave of simultaneous first-time misses. There's a deployment-specific version of this worth calling out: deploying a new version means every application instance starts with an empty local cache, and if hundreds of instances all reach for Redis at once — especially if Redis itself was also just restarted — that's the exact shape of a self-inflicted stampede from Part 7. A controlled warm-up on deploy is cheap insurance against a problem you'd otherwise cause yourself, on a schedule you control.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. The database still matters — read replicas included
&lt;/h2&gt;

&lt;p&gt;"We have Redis, so the database doesn't matter" is wrong in a way that tends to surface at the worst moment: the database is still the source of truth, and every miss eventually reaches it. At high read volume, database read replicas can absorb cache-miss traffic that a single primary couldn't. But replicas introduce their own lag, so a value read from cache and then re-fetched from a replica on a miss can still be stale relative to a very recent write — performance bought with yet another consistency trade-off, the pattern this whole post keeps returning to.&lt;/p&gt;




&lt;h2&gt;
  
  
  16. Backpressure becomes non-optional
&lt;/h2&gt;

&lt;p&gt;At a million requests/sec, an unprotected system facing a slow Redis looks like: requests wait, threads get consumed, connection pools exhaust, the application fails. The alternative is deliberate backpressure — queuing, limits, bounded concurrency — that protects Redis and the database by refusing some work rather than accepting all of it and failing everything. A high-performance system isn't one that accepts unlimited traffic; sometimes the highest-performing thing it can do is say no to some of it on purpose.&lt;/p&gt;




&lt;h2&gt;
  
  
  17. Capacity planning is not just "value size times key count"
&lt;/h2&gt;

&lt;p&gt;10 million objects at 5KB average looks like roughly 50GB of raw data — but that's not the real Redis memory requirement. Keys, metadata, data structure overhead, replication, fragmentation, and operational headroom all add to that number, often substantially. Size a cache from measured actual memory consumption, not from multiplying the serialized value size by the key count.&lt;/p&gt;




&lt;h2&gt;
  
  
  18. Eviction policy is a decision about access patterns, not a default
&lt;/h2&gt;

&lt;p&gt;When memory fills up, something has to go — LRU, LFU, and TTL-based policies all exist for a reason, and the right one depends on the actual access pattern rather than a general recommendation. If a small number of keys are extremely popular, LFU may outperform simple recency-based eviction for that specific workload. The point was never "always use LFU" — it's that the eviction policy should be chosen deliberately, the same principle from Part 2, now with real operational stakes attached.&lt;/p&gt;




&lt;h2&gt;
  
  
  19. Observability has to go deeper as scale grows
&lt;/h2&gt;

&lt;p&gt;A single dashboard is plenty at 1,000 requests/sec. At a million, cluster-level visibility needs node health, CPU, memory, network, connections, commands/sec, latency, evictions, replication lag, and hot keys — and the application side needs L1 hit ratio, L2 hit ratio, misses, Redis errors, database fallback, P95/P99, and request volume. The goal is being able to trace a problem across the whole system, not just see that "something" is elevated somewhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  20. Don't build any of this because it looks impressive
&lt;/h2&gt;

&lt;p&gt;Multi-level caching, distributed Redis, replication, regional deployment, read replicas, and event-driven invalidation are each a real answer to a real problem — and together they're a lot of operational surface area. At 500 requests/sec, none of this is warranted. At 500,000+ requests/sec with strict latency requirements, the conversation genuinely changes. Architecture should follow scale, not the other way around — the same "earn the complexity" principle from Part 8, just at a size where the stakes of getting it wrong are much higher.&lt;/p&gt;




&lt;h2&gt;
  
  
  The biggest lesson: the bottleneck doesn't disappear, it moves
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbnte6vrwg1qs76lzn671.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbnte6vrwg1qs76lzn671.png" alt="Each fix doesn't remove the bottleneck, it relocates it: with no cache the database is the bottleneck, adding Redis moves it to Redis itself, adding clustering moves it to whichever node happens to be hot, and adding a local cache moves it to invalidation, now distributed across every copy" width="799" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every fix in this post follows the same pattern. Add caching, and the database bottleneck eases — but Redis is now the thing under load. Add clustering, and Redis's aggregate capacity problem eases — but now one hot node is the constraint. Add local caching to relieve that node, and the constraint moves again, this time to keeping every local copy consistent. The bottleneck was never eliminated at any step. It relocated, and the job was to notice where it went next.&lt;/p&gt;

&lt;p&gt;That's the actual discipline behind caching at scale: not implementing every technique in this post preemptively, but continuously asking &lt;strong&gt;"where is the bottleneck now?"&lt;/strong&gt; and adding exactly the piece that answers it — no more, no earlier.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this means for the series so far
&lt;/h2&gt;

&lt;p&gt;Caching at small scale can be &lt;code&gt;Application → Redis → Database&lt;/code&gt;. At real scale it can become load balancers, application instances, local caches, a Redis cluster, regional caching, and a database layer with read replicas behind it. More infrastructure was never automatically a better system — the actual skill is knowing when each layer earns its place and why.&lt;/p&gt;

&lt;p&gt;Caching was never really a product decision. Redis is a tool. Memcached is a tool. Local memory is a tool. A CDN is a tool. The architecture comes from understanding your workload, your consistency requirements, your failure modes, and your actual scale — not from which tool has the most features.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've operated a Redis cluster at real scale, what was the metric that actually caught your first hot-key incident — was it a per-node dashboard, or did it take an outage to teach you to look there?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>systemdesign</category>
      <category>redis</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Testing Your Cache: Proving It Actually Works</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Sat, 22 Aug 2026 21:15:50 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/testing-your-cache-proving-it-actually-works-hje</link>
      <guid>https://dev.to/muralidharan_lakshmanan/testing-your-cache-proving-it-actually-works-hje</guid>
      <description>&lt;p&gt;Part 9 built a Cache-Aside implementation with Spring Boot and Redis: TTL, structured keys, serialization, invalidation, database fallback. At that point it's tempting to declare victory — "the cache works." But what does "works" actually mean? Does it work when Redis is empty, or unavailable, or when a thousand requests land at the same instant, or when a cached value expires while the database keeps moving underneath it? And underneath all of that: did the cache actually make anything faster, or does it just look like it should have?&lt;/p&gt;

&lt;p&gt;That's what this post tests.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Don't just test the happy path
&lt;/h2&gt;

&lt;p&gt;The test almost everyone writes first is &lt;code&gt;Request → Redis → HIT → success&lt;/code&gt;. That's useful, but it's one of three paths a cache actually has in production.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjm9vxh2sr5lamsrhykcp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjm9vxh2sr5lamsrhykcp.png" alt="A cache has three paths to test: a request checks Redis and can hit, returning the cached value; miss, falling through to the database and repopulating Redis; or error, taking the fallback path — the HIT path is what most test suites cover, but the ERROR path is the one production actually needs" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The HIT path is the one every test suite has. The ERROR path — Redis throwing a connection exception mid-request — is the one that's usually missing, and it's the one that determines whether an incident stays contained or spreads.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Test scenarios worth defining upfront
&lt;/h2&gt;

&lt;p&gt;For a product API, a reasonably complete list looks like: cache hit (don't call the database), cache miss (call the database and populate the cache), TTL expiration (fetch fresh data once the entry ages out), an update (invalidate the cache), Redis unavailable (fall back appropriately), concurrent requests (avoid redundant database load), database unavailable (fail predictably), a serialization mismatch (fail safely), and a direct performance comparison (cache should measurably reduce latency). Naming these up front turns "test the cache" into something concrete enough to actually write.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Test #1 — cache hit
&lt;/h2&gt;

&lt;p&gt;Suppose Redis already has &lt;code&gt;product:101&lt;/code&gt; cached, and a request comes in for it. The database should never be touched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shouldReturnProductFromCache&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101L&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"MacBook Pro"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1999&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:101"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thenReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;productService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101L&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"MacBook Pro"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getName&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;never&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101L&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assertion that matters most here isn't &lt;code&gt;assertEquals&lt;/code&gt; — it's &lt;code&gt;verify(repository, never())&lt;/code&gt;. That's the line proving the database wasn't touched, not just that the right value came back.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Why that second assertion matters
&lt;/h2&gt;

&lt;p&gt;It's possible for a cache to be returning correct data while still quietly querying the database on every request — &lt;code&gt;Redis HIT → database query anyway → return&lt;/code&gt;. The application looks like it works, the response is correct, and none of that tells you the cache isn't providing its actual benefit. A real cache-hit test checks two separate things: &lt;strong&gt;correctness&lt;/strong&gt; (the value is right) and &lt;strong&gt;efficiency&lt;/strong&gt; (the database wasn't touched to get it). Either one alone can pass while the other silently fails.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Test #2 — cache miss
&lt;/h2&gt;

&lt;p&gt;Now Redis returns &lt;code&gt;null&lt;/code&gt;. The application needs to query the database, return the result, and populate Redis — the full Cache-Aside loop from Part 4:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shouldLoadFromDatabaseOnCacheMiss&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101L&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"MacBook Pro"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1999&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:101"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thenReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101L&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thenReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;productService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101L&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101L&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101L&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:101"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one verifies the whole loop, not just the read.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Test #3 — TTL
&lt;/h2&gt;

&lt;p&gt;TTL is easy to get right in code and wrong in configuration, so it's worth verifying explicitly rather than assuming. For a unit test, you don't need to wait ten minutes — just confirm the cache write was configured with the TTL you expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:101"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;eq&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an integration test, a short TTL — two seconds — lets you actually wait for expiration and confirm the entry disappears when it should.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Unit tests and integration tests catch different bugs
&lt;/h2&gt;

&lt;p&gt;A unit test mocks Redis; an integration test runs against a real instance and exercises real serialization, real TTL behavior, real key formatting, and a real connection. Integration tests catch classes of bugs mocks structurally can't — misconfigured connection settings, a serialization format that doesn't round-trip the way you assumed, a TTL that isn't actually being applied. Both layers earn their place; neither substitutes for the other.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Test #4 — cache invalidation
&lt;/h2&gt;

&lt;p&gt;Suppose Redis has &lt;code&gt;product:101&lt;/code&gt; at &lt;code&gt;$1,999&lt;/code&gt;, and an update changes the database price to &lt;code&gt;$1,899&lt;/code&gt;. The application should delete the cache entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shouldInvalidateCacheAfterUpdate&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101L&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"MacBook Pro"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1899&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thenReturn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;productService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;updateProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:101"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test exists specifically to catch the most common caching bug there is: the database changed, and the cache didn't hear about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Test #5 — Redis is unavailable
&lt;/h2&gt;

&lt;p&gt;Simulate the failure directly and confirm the application falls back to the database rather than failing the request outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:101"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;thenThrow&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RedisConnectionFailureException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Redis unavailable"&lt;/span&gt;
    &lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then verify &lt;code&gt;productRepository.findById(101L)&lt;/code&gt; gets called. This is the try/catch fallback from Part 9, and this test is what proves it's actually wired up.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. But be careful what this test does and doesn't prove
&lt;/h2&gt;

&lt;p&gt;If Redis is down and the application is taking 10,000 requests/sec, every one of them now falls through to the database — and this test will still pass, because "fallback works" was never in question. Whether the &lt;em&gt;database&lt;/em&gt; survives 10,000 requests/sec it wasn't sized for is a completely different question, and this test doesn't answer it. Functional correctness and resilience are separate properties. A unit test proves the fallback path exists; only a load test proves the system survives actually using it — which is exactly what Part 6 and Part 7 covered from the failure-mode side.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Test #6 — concurrent requests
&lt;/h2&gt;

&lt;p&gt;This is arguably the single most valuable test in the whole suite. If a popular key expires and a thousand requests arrive at nearly the same moment, unprotected Cache-Aside turns that into a thousand simultaneous database queries — the cache stampede from Part 7. With request coalescing in place, it should look closer to one request loading the database and the other 999 reading the result once it lands.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. How to actually test that
&lt;/h2&gt;

&lt;p&gt;Fire concurrent calls from a thread pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;ExecutorService&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;Executors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newFixedThreadPool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Callable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;IntStream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;range&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mapToObj&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;productService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101L&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Execute them together, then check how many times the database was actually called. With coalescing working, 100 concurrent application requests should collapse into something close to a single database call — not exactly one, depending on timing and the coalescing strategy, but dramatically fewer than 100. This is a test an ordinary unit test simply cannot catch, because the bug only exists under concurrency.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. Test #7 — database failure
&lt;/h2&gt;

&lt;p&gt;Flip the failure around: Redis misses cleanly, but the database itself is unavailable. The API shouldn't return a bare &lt;code&gt;500&lt;/code&gt; with no useful information — depending on the application, something like a &lt;code&gt;503&lt;/code&gt; with enough context to be debuggable later is the better shape. The property that actually matters here isn't the exact status code; it's that the failure is predictable, observable, and bounded rather than an unhandled exception bubbling up as a mystery.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. Test #8 — serialization
&lt;/h2&gt;

&lt;p&gt;Suppose the cached bytes for &lt;code&gt;product:101&lt;/code&gt; don't match what the current application expects — an old serialized shape sitting next to new deserialization code, the exact versioning problem from Part 9. Deserialization can fail outright here, and a good integration test verifies the application doesn't just crash on it. A reasonable strategy: catch the deserialization failure, delete the bad entry, fall through to the database, and repopulate the cache correctly. That turns a corrupted cache entry into a self-healing miss instead of an application error.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. Test #9 — measure before and after
&lt;/h2&gt;

&lt;p&gt;The question that actually matters outside the engineering team: did this caching work improve anything? "Redis is fast" isn't evidence. Real numbers are — average latency, P95 latency, and database CPU, measured with caching off and then on, under the same load.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lu0ha0ih62cab4tmn9i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lu0ha0ih62cab4tmn9i.png" alt="Prove it with numbers, not " width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Caching isn't valuable because Redis looks good on an architecture diagram — it's valuable because these specific numbers moved, on this specific workload.&lt;/p&gt;




&lt;h2&gt;
  
  
  16. What to actually measure
&lt;/h2&gt;

&lt;p&gt;Two scenarios to benchmark: without the cache (application straight through to the database) and with it (application through Redis, database only on a miss). Track throughput, average latency, P95, P99, database queries per second, CPU, memory, and network traffic — not just the average, which brings us to the next point.&lt;/p&gt;




&lt;h2&gt;
  
  
  17. Why P95 and P99 matter more than the average
&lt;/h2&gt;

&lt;p&gt;A 20ms average latency sounds great in isolation. If P95 is 200ms and P99 is 2 seconds, a meaningful slice of real users are having a genuinely bad experience that the average is quietly hiding. For production systems, tail latency is usually the number that determines whether users notice a problem — a caching benchmark that only reports the average is reporting the least informative number available.&lt;/p&gt;




&lt;h2&gt;
  
  
  18. A simple load test
&lt;/h2&gt;

&lt;p&gt;Tools like JMeter, Gatling, or k6 can drive traffic — say, 1,000 virtual users hitting &lt;code&gt;GET /products/101&lt;/code&gt; for ten minutes, run once with caching disabled and once enabled:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;No cache&lt;/th&gt;
&lt;th&gt;With cache&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;5K/sec&lt;/td&gt;
&lt;td&gt;20K/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;P95&lt;/td&gt;
&lt;td&gt;350 ms&lt;/td&gt;
&lt;td&gt;45 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB CPU&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB queries/sec&lt;/td&gt;
&lt;td&gt;5K/sec&lt;/td&gt;
&lt;td&gt;200/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The exact numbers depend entirely on your application and environment — what matters is running this methodology against your own workload, not adopting someone else's numbers as a benchmark for your system.&lt;/p&gt;




&lt;h2&gt;
  
  
  19. Test the worst case, not just the best one
&lt;/h2&gt;

&lt;p&gt;Testing cache hit, cache hit, cache hit, cache hit will always look fantastic — of course it does, that's the easy path. The tests that actually tell you something: an empty cache, expiration under load, a Redis restart, Redis being fully unavailable, a hot key, a slow database, a traffic spike, concurrent requests racing each other. Production doesn't grade on the good day. It grades on the bad one.&lt;/p&gt;




&lt;h2&gt;
  
  
  20. Test cache recovery
&lt;/h2&gt;

&lt;p&gt;Redis restarts, and every cached entry is gone at once. The application needs to survive the climb back from &lt;code&gt;MISS → database → Redis SET → HIT&lt;/code&gt; repeating across a cold cache under live traffic. Worth measuring explicitly: how fast the cache repopulates, how much database load the recovery window generates, what application latency looks like during that window, and whether a stampede happens on the way back up. A cache restart is a routine operational event — it shouldn't be able to take the application down with it.&lt;/p&gt;




&lt;h2&gt;
  
  
  21. Test a hot key deliberately
&lt;/h2&gt;

&lt;p&gt;Manufacture the scenario from Part 7 on purpose: send a large share of traffic — a million requests, say — at a single key like &lt;code&gt;product:popular&lt;/code&gt;, and watch Redis CPU, Redis network, application latency, and database traffic while it happens. This test earns its keep specifically for applications with popular products, trending content, homepage data, shared configuration, or anything else likely to concentrate traffic on one key.&lt;/p&gt;




&lt;h2&gt;
  
  
  22. Testing isn't only about code
&lt;/h2&gt;

&lt;p&gt;A cache can pass every unit test in the suite and still fail in production, because production has things a test environment doesn't fully replicate: real traffic patterns, real concurrency, real network latency, real failure timing, real data shapes, real deployment behavior. That gap is exactly why a single layer of testing was never going to be enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  23. Four levels of cache testing
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7t3c02g6r09t43jdyzzc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7t3c02g6r09t43jdyzzc.png" alt="Four levels of cache testing: unit tests cover cache hit, miss, invalidation, and error handling and should be the most numerous; integration tests exercise real Redis, serialization, TTL, and key formatting; load tests cover concurrency and hot keys; failure tests cover Redis and database outages and should be the fewest, since they're the most expensive to run" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Unit tests: cache hit, cache miss, invalidation, error handling — fast, isolated, and there should be a lot of them. Integration tests: Spring Boot against a real Redis, real serialization, real TTL and key behavior. Load tests: thousands of requests, concurrent users, hot keys, sustained misses — this is where you learn how the system behaves under pressure rather than in isolation. Failure tests: Redis unavailable, Redis restarting, a slow database, a database that's fully down, network faults — this is where you learn whether the architecture is actually resilient or just untested.&lt;/p&gt;




&lt;h2&gt;
  
  
  24. What to still monitor in production
&lt;/h2&gt;

&lt;p&gt;Testing builds confidence before release; production observability is what tells you whether that confidence held up. At minimum: cache hit ratio, miss ratio, cache latency, Redis memory and CPU, evictions, connections, errors, hot keys, database fallback traffic, and application latency — and critically, correlated with each other rather than watched in isolation. A hit ratio drop that's followed by rising database traffic, rising database CPU, and rising application P95 tells a complete story that no single metric tells on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  25. The metric that matters more than hit ratio
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsadu4xwdoxmkshva85l1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsadu4xwdoxmkshva85l1.png" alt="99.9% is a great hit ratio, but it isn't the whole story: at 10,000 requests per second, 99.9% still means 10 misses per second, and if each of those misses costs 2 seconds, that small slice of traffic is enough on its own to dominate the P99" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A 99.9% hit ratio at 10,000 requests/sec still means 10 requests/sec are hitting the database — and if each of those misses is a genuinely expensive 2-second query, that 0.1% is not a rounding error, it's very possibly your entire P99 problem. The question worth asking alongside "what's our hit ratio" is "what does a miss actually cost us" — the two questions together tell you something neither one tells you alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  26. What "the cache works" should actually mean
&lt;/h2&gt;

&lt;p&gt;Put together, a production-ready cache should demonstrate correctness (the right data comes back), performance (hits are measurably faster than the database path), efficiency (database traffic is genuinely reduced), resilience (behavior stays predictable when Redis fails), scalability (concurrency and traffic spikes don't cause a stampede), recoverability (the cache can rebuild itself after a failure), and observability (you can see when it's helping, and when it's quietly not). That's a considerably more useful bar than "the demo worked."&lt;/p&gt;




&lt;h2&gt;
  
  
  The bigger lesson
&lt;/h2&gt;

&lt;p&gt;Testing a cache was never really about proving &lt;code&gt;SET key value&lt;/code&gt; works — Redis already knows how to do that. It's about proving that &lt;code&gt;Application → Cache → Database&lt;/code&gt; behaves correctly across everything working, something going wrong, traffic increasing, and data changing, all at once and in combination. That's the actual difference between code that works and a system that can survive contact with production.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's the test in your suite that actually caught a real caching bug before it shipped — was it a unit test, or did it take a load test or a chaos experiment to surface?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>testing</category>
      <category>redis</category>
      <category>springboot</category>
    </item>
    <item>
      <title>Let's Build a Cache: Redis + Spring Boot</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Fri, 21 Aug 2026 02:13:40 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/lets-build-a-cache-redis-spring-boot-m78</link>
      <guid>https://dev.to/muralidharan_lakshmanan/lets-build-a-cache-redis-spring-boot-m78</guid>
      <description>&lt;p&gt;The first eight parts of this series covered why caching improves performance, how hits and misses work, TTL and eviction, cache-aside and the other patterns, invalidation, Redis vs. Memcached, stampedes and hot keys, and how to design a production-ready cache architecture. Now let's actually build one.&lt;/p&gt;

&lt;p&gt;The stack is one a lot of enterprise Java teams already run: Spring Boot, Redis, and a database behind it. The goal isn't a caching framework — it's something simple enough to fully understand and realistic enough to use as a real starting point.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. What we're building
&lt;/h2&gt;

&lt;p&gt;A simple product service: &lt;code&gt;GET /products/{id}&lt;/code&gt;, backed by a database row like &lt;code&gt;{ "id": 101, "name": "MacBook Pro", "price": 1999.00 }&lt;/code&gt;. Without caching, every request — even a thousand requests for the same product — goes all the way to the database. With caching, it's Cache-Aside, the pattern from Part 4: check Redis first, fall back to the database on a miss, populate Redis for next time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpm08n1dt6z2pxectoyck.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpm08n1dt6z2pxectoyck.png" alt="What we're building: a REST client calls Spring Boot, which checks Redis and only falls through to the database on a miss — the shape this entire post implements piece by piece" width="800" height="200"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The stack
&lt;/h2&gt;

&lt;p&gt;Java, Spring Boot, Spring Data Redis, Redis itself, JPA, and a relational database — Postgres or MySQL, it doesn't matter much for this example. The request path is client → Spring Boot → Redis → (on a miss) → database.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Start with the database
&lt;/h2&gt;

&lt;p&gt;A plain &lt;code&gt;Product&lt;/code&gt; entity and repository, nothing caching-specific yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Id&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// getters and setters&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;ProductRepository&lt;/span&gt;
        &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without caching, the service just goes straight through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProductRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;ProductService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ProductRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ProductNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request hits the database. Now let's introduce Redis.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Add Redis
&lt;/h2&gt;

&lt;p&gt;Spring Data Redis is the dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dependency&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;groupId&amp;gt;&lt;/span&gt;org.springframework.boot&lt;span class="nt"&gt;&amp;lt;/groupId&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;artifactId&amp;gt;&lt;/span&gt;spring-boot-starter-data-redis&lt;span class="nt"&gt;&amp;lt;/artifactId&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dependency&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the connection is standard Spring Boot configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spring&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6379&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. The simplest Cache-Aside implementation
&lt;/h2&gt;

&lt;p&gt;Using &lt;code&gt;RedisTemplate&lt;/code&gt; directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ProductRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;RedisTemplate&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;ProductService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;ProductRepository&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;RedisTemplate&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;repository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;redisTemplate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="nf"&gt;getProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"product:"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

        &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;cachedProduct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
                &lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cachedProduct&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cachedProduct&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ProductNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's Cache-Aside, fully implemented. On the first request for &lt;code&gt;product:101&lt;/code&gt;, Redis misses, the database gets queried, and the result gets written back to Redis before returning. On the second request for the same ID, Redis hits and the database never gets touched — that gap between the two is where the entire performance benefit comes from.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Add a TTL
&lt;/h2&gt;

&lt;p&gt;Right now we're storing values indefinitely, which means a stale product can live in the cache forever if invalidation ever fails. Add an expiration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;set&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The entry now expires automatically after ten minutes — a safety net that holds even when explicit invalidation doesn't fire, which we'll get to shortly.&lt;/p&gt;

&lt;p&gt;Resist hardcoding &lt;code&gt;Duration.ofMinutes(10)&lt;/code&gt; everywhere it's needed, though. Once you have a product TTL, a user-profile TTL, a recommendations TTL, and a configuration TTL scattered through the codebase, tuning any of them means a code change and a redeploy. Externalizing them instead —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— means freshness can be tuned from configuration, which matters more than it looks like it should the first time you need to change one in production without shipping code.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Cache keys matter
&lt;/h2&gt;

&lt;p&gt;We used &lt;code&gt;product:101&lt;/code&gt; rather than a bare &lt;code&gt;101&lt;/code&gt;, and that's deliberate — a bare numeric key can collide across object types (&lt;code&gt;101&lt;/code&gt; the product, &lt;code&gt;101&lt;/code&gt; the customer, &lt;code&gt;101&lt;/code&gt; the order all fighting over the same slot). Namespacing avoids that: &lt;code&gt;product:101&lt;/code&gt;, &lt;code&gt;customer:101&lt;/code&gt;, &lt;code&gt;order:101&lt;/code&gt;. Going one step further, &lt;code&gt;product:v1:101&lt;/code&gt; leaves room to version the cached shape later, the same technique covered in Part 8.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Serialization — the part that's easy to skip past
&lt;/h2&gt;

&lt;p&gt;Redis stores bytes, not Java objects, so something has to convert between them. JSON is the common choice — readable, easy to inspect, and it round-trips cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Java Object → JSON → Redis
Redis → JSON → Java Object
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It isn't free, though — serialization costs CPU, memory, and bandwidth, and changing the format later can break compatibility with whatever's already sitting in the cache. Treat the serialization format as an architectural decision made once, not a configuration line picked without much thought.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Cache invalidation on updates
&lt;/h2&gt;

&lt;p&gt;Now the write path. &lt;code&gt;PUT /products/101&lt;/code&gt; updates the price — the database gets the update, and the cache entry gets deleted rather than updated in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="nf"&gt;updateProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"product:"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UPDATE → DELETE CACHE&lt;/code&gt;, and the next read rebuilds the entry from the now-current database row. This is the same pattern from Part 5: deleting is simpler than updating in place because the cache is a temporary copy, and when the source of truth changes, the cleanest move is to stop trusting the old copy rather than try to patch it.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. What if cache invalidation itself fails?
&lt;/h2&gt;

&lt;p&gt;The real production scenario: the database update succeeds, but the Redis delete fails. Now the database says &lt;code&gt;$1,899&lt;/code&gt; and Redis still says &lt;code&gt;$1,999&lt;/code&gt;. This is exactly the dual-write problem from Part 5, and the TTL from step 6 is what keeps it from being permanent — the stale entry eventually expires on its own. TTL isn't a substitute for invalidation here; it's the safety net underneath it.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. What if Redis is completely down?
&lt;/h2&gt;

&lt;p&gt;The read path shouldn't treat Redis as a hard dependency. A reasonable Cache-Aside implementation catches a connection failure and falls through to the database rather than failing the request outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redisTemplate&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RedisConnectionFailureException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Log and continue to database&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(...);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the right instinct, but it isn't the whole answer — if Redis is down and traffic is high, this fallback alone can be exactly the flood that overwhelms the database, the failure mode from Part 6 and Part 7. That's what request coalescing, rate limiting, circuit breakers, local caching, and bounded concurrency are for; this try/catch is necessary, not sufficient.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. Add metrics
&lt;/h2&gt;

&lt;p&gt;An unmeasured cache is hard to operate. At minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cache.hit"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;increment&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;meterRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"cache.miss"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;increment&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;plus &lt;code&gt;cache.error&lt;/code&gt; and &lt;code&gt;cache.latency&lt;/code&gt;. From hits and misses you get the hit ratio — 9,900 hits against 100 misses is 99% — but a high ratio on its own doesn't mean the system is healthy. A 99% hit ratio is meaningless if the 1% of misses are the expensive ones.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. The N+1 cache problem
&lt;/h2&gt;

&lt;p&gt;Here's a subtler issue that shows up once caching is actually working. Suppose an endpoint returns an order with a customer and four products:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe8iodddy5yw5alkoxrcj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe8iodddy5yw5alkoxrcj.png" alt="The N+1 cache problem: GET /orders/1001 fans out into five separate cache operations — one customer lookup and four product lookups — all sitting on the same request's critical path" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One API request just became five cache operations. Each one is individually cheap, but at real scale that adds up, and it's easy to miss because no single call looks expensive in isolation. Caching doesn't remove an N+1 access pattern — it just makes each hop in it cheaper. Measure the whole request path, not just the cache calls that look slow on their own.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. Don't cache everything
&lt;/h2&gt;

&lt;p&gt;Good candidates share a shape: frequent reads, expensive computation or database access, infrequent changes, and staleness the business can tolerate. Poor candidates: highly volatile data, sensitive data without real access controls, data nobody actually requests often, data that's cheap to fetch anyway, and anything where a stale value is genuinely unacceptable.&lt;/p&gt;

&lt;p&gt;A cache should exist because it solves a demonstrated performance problem — the same principle from Part 8's "earn the complexity" argument, just applied at the level of an individual field instead of an architecture layer. It shouldn't be a box ticked on an architecture diagram because caching is what's done around here.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. Putting the read and write paths together
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6j4lsfurswk5415dg26b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6j4lsfurswk5415dg26b.png" alt="The two paths this implementation supports: GET /products/101 checks Redis, returns immediately on a hit, or falls through to the database and populates Redis on a miss; PUT /products/101 updates the database, deletes the Redis entry, and lets the next GET rebuild it from scratch" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a simple, understandable caching architecture — which is exactly the point. Nothing here should surprise anyone reading it for the first time during an incident.&lt;/p&gt;




&lt;h2&gt;
  
  
  16. What we've built
&lt;/h2&gt;

&lt;p&gt;Working through the steps above, this implementation now has Cache-Aside on the read path, a TTL so entries expire even if invalidation fails, explicit invalidation on writes, structured and versioned cache keys, a deliberate serialization format, basic hit/miss/error observability, and a Redis connection that isn't a hard dependency for every request. The database stays authoritative throughout — Redis is a fast, disposable copy of it, never the other way around.&lt;/p&gt;




&lt;h2&gt;
  
  
  17. Is this production-ready yet?
&lt;/h2&gt;

&lt;p&gt;Not quite — this is a solid foundation, but a high-traffic production system needs more of what Part 8 covered in full: Redis high availability and clustering, connection pooling, timeouts, circuit breakers, request coalescing, hot-key protection, cache warming, TTL jitter, security, monitoring, and alerting.&lt;/p&gt;

&lt;p&gt;There's also a question this post hasn't touched at all: &lt;strong&gt;testing.&lt;/strong&gt; How do you actually prove caching improved anything? How do you test a cache hit, a cache miss, Redis being unavailable, a stale entry, concurrent requests racing each other, expiration timing, and a database failure underneath all of it?&lt;/p&gt;

&lt;p&gt;That's genuinely a different skill from writing the cache-aside logic itself, and it's where the next part of this series is headed.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've built something close to this, what was the first thing that broke once it hit real traffic — the invalidation path, a serialization mismatch, or something in the failure handling you hadn't tested?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>redis</category>
      <category>springboot</category>
      <category>java</category>
    </item>
    <item>
      <title>Designing a Production-Ready Cache</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Thu, 20 Aug 2026 02:25:56 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/designing-a-production-ready-cache-mmp</link>
      <guid>https://dev.to/muralidharan_lakshmanan/designing-a-production-ready-cache-mmp</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;Adding a cache isn't the architecture. &lt;strong&gt;Designing what happens around the cache is the architecture.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Start with the problem
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Cache-aside is still the right starting point
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The write path is just as simple: update the database, then delete the cache entry, and let the next read rebuild it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. What if Redis is down?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Cache failure handling is not an afterthought here — it's as important as the cache design itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Don't let cache failure become database failure
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Layered together, this becomes a &lt;strong&gt;multi-level cache&lt;/strong&gt;: 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Redis needs high availability too
&lt;/h2&gt;

&lt;p&gt;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: &lt;strong&gt;don't create a single point of failure in the component you're depending on for performance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. TTL strategy, and why jitter matters at scale
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 (&lt;code&gt;TTL = 60 minutes + random(0–5 minutes)&lt;/code&gt;) spreads that expiration across a window instead of a single instant. It's a small decision that prevents a surprisingly large spike.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Cache warming
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. What if the database is also slow?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Bringing it together
&lt;/h2&gt;

&lt;p&gt;Here's what all of the above looks like assembled into one system, rather than as separate techniques:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0x3omji9q8szod1apv3p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0x3omji9q8szod1apv3p.png" alt="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" width="799" height="620"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. But don't build all of this on day one
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fysdk8j9ls4687f8rpla0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fysdk8j9ls4687f8rpla0.png" alt="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" width="799" height="255"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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. &lt;strong&gt;Add complexity in response to a demonstrated problem&lt;/strong&gt; — not a hypothetical one.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Observability: watch more than the hit ratio
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Far02rrikfdxldahta8hq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Far02rrikfdxldahta8hq.png" alt="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" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. Security is part of the cache design, not separate from it
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;user:123:profile&lt;/code&gt; is not the same thing as &lt;code&gt;profile:123&lt;/code&gt; the moment the underlying data carries user-specific authorization — a cache key design mistake can quietly become a security bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. Cache keys are part of the architecture, not an implementation detail
&lt;/h2&gt;

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

&lt;p&gt;Versioning the key itself is a small trick that pays off repeatedly: when a cached object's shape changes — say, a &lt;code&gt;currency&lt;/code&gt; field gets added to a product payload — bumping &lt;code&gt;product:v1:123&lt;/code&gt; to &lt;code&gt;product:v2:123&lt;/code&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. What if the cache is completely lost?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. The production checklist
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  The biggest lesson from this series
&lt;/h2&gt;

&lt;p&gt;Caching was never really about Redis, or Memcached, or any particular technology. At its core, it's a trade-off.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu6277ambyxbwcjc7iepv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu6277ambyxbwcjc7iepv.png" alt="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" width="800" height="653"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;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?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>systemdesign</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>When Caching Goes Wrong: Cache Stampede, Hot Keys &amp; Thundering Herds</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Tue, 18 Aug 2026 23:05:31 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/when-caching-goes-wrong-cache-stampede-hot-keys-thundering-herds-1jjb</link>
      <guid>https://dev.to/muralidharan_lakshmanan/when-caching-goes-wrong-cache-stampede-hot-keys-thundering-herds-1jjb</guid>
      <description>&lt;p&gt;Caching is supposed to make systems faster. Here's the surprising part: a cache can sometimes make a system slower — and in extreme cases, bring it down entirely.&lt;/p&gt;

&lt;p&gt;Picture this. Your application normally handles 10,000 requests/sec, and your database comfortably handles 500 queries/sec, because the cache is absorbing almost everything. Then one popular cache entry expires. Suddenly all 10,000 requests miss at once, all 10,000 become database queries, and the database was never sized for that. CPU climbs, connection pools fill up, response times rise, requests start timing out — and then clients retry, which sends the database &lt;em&gt;more&lt;/em&gt; traffic, not less.&lt;/p&gt;

&lt;p&gt;That's how a small cache event turns into a production incident. This post covers the failure modes behind it: cache stampede, thundering herd, hot keys, cache penetration, cache avalanche, and the retry storms that make all of them worse — plus the standard defenses for each.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Cache stampede
&lt;/h2&gt;

&lt;p&gt;Suppose &lt;code&gt;product:123&lt;/code&gt; has a 10-minute TTL and thousands of users are requesting it. For ten minutes, every request is a hit. Then the TTL expires, and if 5,000 requests arrive in roughly the same instant, every single one of them sees a miss and goes straight to the database.&lt;/p&gt;

&lt;p&gt;The cache was protecting the database. Then, for one bad moment, it stopped.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Why this is so dangerous
&lt;/h2&gt;

&lt;p&gt;Numbers make this concrete. Say normal traffic is 20,000 requests/sec with a 99% hit ratio — the database sees roughly 200 requests/sec, which is nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn4o6q2mikyd9408a4c4w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn4o6q2mikyd9408a4c4w.png" alt="One key expires and the database sees 100x the traffic: 20,000 requests/sec at a 99% hit ratio normally means about 200 requests/sec reach the database, but when one popular key expires, the same 20,000 requests/sec all miss at once and roughly 20,000 requests/sec hit the database instead" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now one popular key expires. The same 20,000 requests/sec are still arriving, but now all of them miss — the database goes from 200 requests/sec to roughly 20,000. Nothing about the traffic changed. One cache entry going cold multiplied database load a hundred times over, and that's exactly the kind of jump that turns a healthy database into a struggling one within seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. The thundering herd problem
&lt;/h2&gt;

&lt;p&gt;Thundering herd is the general name for this shape of problem: a large number of requests are waiting on the same resource, and when it becomes available (or in this case, unavailable), they all rush it simultaneously. The database isn't necessarily slow — the problem is that far too many requests are doing the exact same expensive work at the exact same moment.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The simple fix: let one request do the work
&lt;/h2&gt;

&lt;p&gt;If a thousand requests miss the same key at once, there's no reason for a thousand identical database queries. Instead, let one request load the data while everyone else waits for that single result.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1gwcismqnv23mcwr73s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm1gwcismqnv23mcwr73s.png" alt="Request coalescing: 1,000 requests miss the same key, but only the first one acquires the work and queries the database — the other 999 wait and receive the same result once the cache is populated, instead of each repeating the identical query" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is usually called &lt;strong&gt;request coalescing&lt;/strong&gt; or &lt;strong&gt;single flight&lt;/strong&gt;. It's a genuinely simple idea with an outsized effect: we're not making the database faster, we're just stopping 999 requests from doing work that's already being done.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Locking
&lt;/h2&gt;

&lt;p&gt;One way to implement coalescing is a distributed lock: on a miss, try to acquire a lock for that key; whoever gets it queries the database, populates the cache, and releases the lock; everyone else waits, then reads the now-populated cache once it's released.&lt;/p&gt;

&lt;p&gt;It works well, but a distributed lock brings its own list of things to get right — lock expiration, deadlocks, a process crashing while holding the lock, who owns the lock, and what retry behavior looks like for the requests waiting on it. Don't reach for distributed locking as a default; reach for it once you've confirmed you need it.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Randomized TTL — add some jitter
&lt;/h2&gt;

&lt;p&gt;Here's a much cheaper technique for a related problem. If 100,000 cache entries were all created around the same time with a flat one-hour TTL, a lot of them expire together an hour later. Add a small random offset instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TTL = 60 minutes + random(0–5 minutes)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now expirations spread across a window instead of landing on a single instant. This is &lt;strong&gt;TTL jitter&lt;/strong&gt; — simple to add, and surprisingly effective at preventing synchronized expiry from becoming synchronized load.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Hot keys
&lt;/h2&gt;

&lt;p&gt;Different problem, same root cause. Imagine 10 million cached keys — plenty of spread on paper — but 80% of traffic is for exactly one of them, say &lt;code&gt;product:iphone&lt;/code&gt;. That single key is now a &lt;strong&gt;hot key&lt;/strong&gt;, and it doesn't matter how many other keys exist; the traffic isn't evenly distributed across them.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Why hot keys are dangerous
&lt;/h2&gt;

&lt;p&gt;If a single cache node handles 50,000 requests/sec but one key alone is getting 200,000 requests/sec, that key becomes a bottleneck regardless of how much headroom the rest of the cluster has.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdqlfr8pk1afd06gi6r18.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdqlfr8pk1afd06gi6r18.png" alt="Hot keys don't spread out just because the cluster does: four nodes carry normal, roughly even traffic while one node holds product:iphone at 200,000 requests/sec against a 50,000/sec capacity, and adding more nodes doesn't help because the same key keeps mapping to the same node" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This matters most in distributed caches specifically, because adding more nodes doesn't fix it — the hot key still maps to the same node it always did. You've spread the &lt;em&gt;average&lt;/em&gt; load across the cluster; the one key that's actually the problem never moved.&lt;/p&gt;

&lt;p&gt;The usual responses: keep the hottest data in application-local memory too, so it doesn't have to reach the distributed cache on every request; replicate the hot key across multiple nodes instead of pinning it to one; apply request coalescing specifically to that key; or, if the data doesn't change often, simply give it a longer TTL to reduce how often it needs refreshing at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Cache penetration
&lt;/h2&gt;

&lt;p&gt;A different failure shape entirely. Suppose users request &lt;code&gt;product:999999999&lt;/code&gt; — a product that doesn't exist. Every request misses the cache, queries the database, gets &lt;code&gt;NOT FOUND&lt;/code&gt;, and the next request does the exact same round trip. The cache isn't helping, because there's nothing valid to cache.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;cache penetration&lt;/strong&gt;, and it's particularly nasty when it's driven by scraping, enumeration, or a bug generating IDs that were never real to begin with.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. Cache the "not found" result
&lt;/h2&gt;

&lt;p&gt;The fix is almost too simple: cache the negative result too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;product:999999999 → NOT_FOUND
TTL = 60 seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the next request for that ID is a cache hit, even though the answer is "it doesn't exist" — no database call needed. The one thing to watch: don't cache a negative result for hours, since the object might legitimately get created shortly afterward. Negative caching generally wants a short TTL specifically because "not found today" and "will never exist" are different claims.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Cache avalanche
&lt;/h2&gt;

&lt;p&gt;Now combine the timing problem with scale. If thousands of entries were all loaded around the same time with the same TTL, they can all expire together — a &lt;strong&gt;cache avalanche&lt;/strong&gt;. It can also be triggered by a cache cluster failure, a mass invalidation, an application restart, a deployment, or a network blip.&lt;/p&gt;

&lt;p&gt;Whatever the trigger, the shape is the same: cache failure leads to a cache-miss explosion, which leads to a database traffic explosion, which leads to database slowdown, application slowdown, timeouts, retries, and then even more traffic than before. That's a cascading failure, and it's the same underlying mechanics as a stampede — just triggered by breadth instead of one popular key.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. The retry problem makes it worse
&lt;/h2&gt;

&lt;p&gt;Here's the part that turns a bad moment into a genuine outage: when a request times out, the client retries. Retry, timeout, retry, timeout — instead of reducing load on a struggling system, the system generates more of it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffg4st48g5gswkroce0gy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffg4st48g5gswkroce0gy.png" alt="The retry loop that turns a blip into an outage: cache misses spike, database traffic spikes, the database slows down, requests time out, clients retry, and traffic increases again — feeding straight back into another round of cache misses, each pass adding more load than the last" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The database didn't fail once here. It failed, and then got asked to fail again, faster, by the same clients that just timed out. This is why caching problems and retry storms are dangerous specifically &lt;em&gt;together&lt;/em&gt; — a resilient system needs bounded retries, exponential backoff, jitter on the backoff itself, timeouts, circuit breakers, and rate limits. Caching is only one part of the resilience story; the retry behavior around it is just as load-bearing.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. Cache warming
&lt;/h2&gt;

&lt;p&gt;Rather than letting the application start with a cold, empty cache, you can proactively load the data you already know will be popular. Before Black Friday, preload the popular products. Before a major product launch, ticket sale, or marketing campaign, warm the keys you already know will spike. You don't want your first million users of the day to be the ones who happen to warm your cache for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. Stale-while-revalidate, again
&lt;/h2&gt;

&lt;p&gt;We covered this in Part 5 as an invalidation technique — it's just as useful here as a stampede defense. Instead of deleting an expired value immediately and forcing every subsequent request to wait on the database, serve the existing value while refreshing it in the background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User → stale value → response immediately
              ↓
       background refresh → cache updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This trades a small amount of freshness for a large amount of availability and latency stability. As always, whether that trade is acceptable is a business question, not a technical one.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. Don't treat the cache as a black box
&lt;/h2&gt;

&lt;p&gt;The single biggest observability mistake is only watching the hit ratio. A 99% hit ratio looks great in isolation, but it doesn't tell you what happens during the other 1%, or what happens to that number under stress.&lt;/p&gt;

&lt;p&gt;Also watch: miss rate, eviction rate, memory usage, latency, key distribution, hot keys specifically, connection count, errors, and timeouts. Above all, watch &lt;strong&gt;what happens to database traffic when cache performance degrades&lt;/strong&gt; — that's usually the single most important signal in the whole system, because it's the one that tells you whether your cache is a performance optimization or a hidden single point of failure.&lt;/p&gt;




&lt;h2&gt;
  
  
  16. A more useful mental model
&lt;/h2&gt;

&lt;p&gt;Most people design a cache by thinking &lt;code&gt;Request → Cache → Database&lt;/code&gt; and stop there. The more useful model asks what happens at the miss branch specifically — is there a lock, a coalescing layer, a TTL strategy, something standing between "everyone missed at once" and "everyone queries the database at once"?&lt;/p&gt;

&lt;p&gt;The goal was never just "make the cache fast." It's &lt;strong&gt;"make the system behave predictably when the cache is slow, empty, overloaded, or unavailable."&lt;/strong&gt; That reframing is worth more than any individual technique in this post.&lt;/p&gt;




&lt;h2&gt;
  
  
  17. Five problems worth remembering
&lt;/h2&gt;

&lt;p&gt;If you keep nothing else from this post, keep these five, matched to their standard fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache stampede&lt;/strong&gt; — many requests miss the same key at once. Fix with request coalescing, locking, or stale-while-revalidate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hot key&lt;/strong&gt; — one key receives disproportionate traffic. Fix with local caching, replication, a longer TTL, or coalescing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache penetration&lt;/strong&gt; — requests repeatedly ask for data that doesn't exist. Fix with negative caching, input validation, or Bloom filters at large scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache avalanche&lt;/strong&gt; — many keys expire or disappear together. Fix with TTL jitter, cache warming, staggered expiration, and a resilient fallback path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry storm&lt;/strong&gt; — failures cause aggressive client retries. Fix with exponential backoff, jitter, bounded retries, circuit breakers, and rate limiting.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The bigger lesson
&lt;/h2&gt;

&lt;p&gt;"Put frequently used data in memory and everything gets faster" is true — right up until it isn't. At production scale, the real questions are what happens when the cache expires, what happens when 10,000 requests miss at the same instant, what happens when one key gets disproportionately popular, what happens when the cache goes down entirely, and what happens when clients start retrying into all of that.&lt;/p&gt;

&lt;p&gt;Those questions are what separate a cache that works in a demo from a caching architecture that survives production. And the principle underneath all of them is the same one this whole series keeps returning to: &lt;strong&gt;a cache should protect your database, not become another single point of failure.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Has your system ever hit one of these five in production — and which one? Stampede and retry storms tend to travel together in my experience; curious if that matches what others have seen.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>systemdesign</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Redis vs. Memcached: What Are We Really Getting?</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Tue, 18 Aug 2026 00:05:44 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/redis-vs-memcached-what-are-we-really-getting-l32</link>
      <guid>https://dev.to/muralidharan_lakshmanan/redis-vs-memcached-what-are-we-really-getting-l32</guid>
      <description>&lt;p&gt;By now, we've covered why caching improves performance, how caches work, where they can live, common caching patterns, and why invalidation is hard. Let's make things concrete.&lt;/p&gt;

&lt;p&gt;When someone says &lt;em&gt;"let's add a distributed cache,"&lt;/em&gt; two names almost always come up: &lt;strong&gt;Redis&lt;/strong&gt; and &lt;strong&gt;Memcached&lt;/strong&gt;. Both are extremely fast, both store data primarily in memory, and both can dramatically reduce database load.&lt;/p&gt;

&lt;p&gt;So the obvious question is which one to use. But the answer isn't "Redis is better" — that's too simplistic. The better question is: &lt;strong&gt;what problem are you trying to solve?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  1. What is Memcached?
&lt;/h2&gt;

&lt;p&gt;Memcached is essentially a high-performance distributed key-value cache. You give it a key, it gives you back a value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user:123 → {name: "John", age: 42}

GET user:123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the basic idea, and Memcached keeps it deliberately simple. Its job is to store temporary data in memory and retrieve it very quickly. That simplicity is one of its biggest strengths.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. What is Redis?
&lt;/h2&gt;

&lt;p&gt;Redis also started as a fast in-memory key-value store, but it's evolved into something broader. It supports several data structures — strings, hashes, lists, sets, sorted sets, and streams — so instead of storing a flat JSON blob under &lt;code&gt;user:123&lt;/code&gt;, you can store a structured hash with individual fields. Redis also gets used for counters, leaderboards, queues, distributed coordination, rate limiting, session storage, pub/sub, and event streams.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fouafc2bu1scpqpqymghn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fouafc2bu1scpqpqymghn.png" alt="Two different data models: Memcached maps a key straight to a value — one shape, get it whole or not at all — while Redis maps a key to a choice of structures (string, hash, list, set, sorted set, stream) so the structure matches the problem" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The simplest way to hold the distinction: Memcached says &lt;em&gt;"I need a really fast temporary cache."&lt;/em&gt; Redis says &lt;em&gt;"I need a really fast in-memory data platform that can also act as a cache."&lt;/em&gt; Memcached focuses on simplicity. Redis gives you significantly more capability — for a price we'll get to.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Redis can be more than a cache — and that's a warning too
&lt;/h2&gt;

&lt;p&gt;Imagine your architecture starts simple: Redis as a cache. Then someone notices Redis can also handle session data, so it does. Then rate limiting gets added. Then someone wires up Redis Streams for events. Each step is individually reasonable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2xrf3iwgz8upzd2um249.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2xrf3iwgz8upzd2um249.png" alt="From cache to critical infrastructure: adding sessions is still disposable, but once rate limiting and streams join the cache, Redis has crossed from data you can casually lose to state you can't rebuild" width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the architectural lesson: the more responsibilities you put into Redis, the less comfortable you should be treating it as disposable cache infrastructure. If Redis is only a cache, you can usually rebuild it. If it contains important state, losing it becomes a much more serious conversation.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Persistence
&lt;/h2&gt;

&lt;p&gt;Redis can be configured with persistence so data survives a restart — useful once Redis is holding more than disposable cache entries. Memcached is much more cache-oriented: if the server restarts, you generally assume the cached data is gone, and that's fine as long as the architecture was designed with that assumption in mind. A cache should usually be rebuildable — persistence is what you reach for once something stops being "just a cache."&lt;/p&gt;




&lt;h2&gt;
  
  
  5. If Redis goes down, what happens?
&lt;/h2&gt;

&lt;p&gt;This is a question every architect should ask before it happens in production, not after.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ubwx6oy6zovhvnc4gwp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ubwx6oy6zovhvnc4gwp.png" alt="What happens when the cache disappears: under normal operation Redis absorbs about 99% of requests and the database sees a light load, but when Redis is unavailable every request falls straight through and the database is overloaded all at once" width="799" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ideally, Redis becoming unavailable means a clean fallback: cache miss, go to the database. But if Redis normally absorbs 99% of traffic and it suddenly disappears, all of that traffic lands on the database at once — overload, slow responses, timeouts, and the application degrading along with it.&lt;/p&gt;

&lt;p&gt;Redis isn't dangerous because it's slow. It's dangerous because it's so effective at absorbing traffic that the rest of the system may never have been tested without it. We'll dig into this failure mode properly in the next part of this series.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Scaling
&lt;/h2&gt;

&lt;p&gt;Both Redis and Memcached scale horizontally, but the shape differs. Memcached typically uses a straightforward distributed model — keys spread across nodes with a simple hashing scheme. Redis also supports clustering, but Redis clustering brings additional considerations: partitioning, replication, failover, topology, and resharding.&lt;/p&gt;

&lt;p&gt;The lesson isn't that one scales and the other doesn't — both do. The real question is &lt;strong&gt;how much operational complexity you're willing to take on.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Performance
&lt;/h2&gt;

&lt;p&gt;You'll hear "Redis is faster" and "Memcached is faster" from different people, and in practice, both can be extremely fast for a basic &lt;code&gt;GET&lt;/code&gt;/&lt;code&gt;SET&lt;/code&gt;. For most applications the difference won't matter — your actual bottleneck is more likely to be the network, serialization, application processing, connection pooling, or the database itself.&lt;/p&gt;

&lt;p&gt;Don't pick a caching technology off a microbenchmark. Ask instead: does it meet my application's actual latency and throughput requirements?&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Memory efficiency and eviction
&lt;/h2&gt;

&lt;p&gt;Memcached's simplicity gives it relatively straightforward memory management, which matters if you're storing millions of tiny objects and every byte counts. Redis's richer data structures can carry more memory overhead depending on which ones you use. The right answer depends on key size, value size, entry count, structure choice, and metadata overhead — measure your actual workload rather than assuming.&lt;/p&gt;

&lt;p&gt;Both support eviction policies (Redis offers LRU, LFU, TTL-based, and no-eviction configurations; Memcached has its own caching-oriented mechanism). We covered why eviction matters back in Part 2 — the architectural lesson there still applies: know what happens when your cache runs out of memory before production finds out for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Where Redis pulls ahead: counters, rate limiting, leaderboards
&lt;/h2&gt;

&lt;p&gt;A few examples where Redis's extra structure earns its complexity. For &lt;strong&gt;rate limiting&lt;/strong&gt;, an atomic counter per user makes a request-per-minute limit straightforward to implement correctly under concurrency — something Memcached can approximate but wasn't built around. For &lt;strong&gt;leaderboards&lt;/strong&gt;, Redis Sorted Sets are purpose-built for maintaining and efficiently querying ranked data; Memcached's flat key-value model isn't designed for that kind of query at all.&lt;/p&gt;

&lt;p&gt;These aren't caching problems in the traditional sense — they're state problems that happen to want the same speed a cache provides.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. When Memcached makes perfect sense
&lt;/h2&gt;

&lt;p&gt;Redis gets most of the attention, but that doesn't make Memcached obsolete. If your requirements are simple key-value caching, temporary data, a large number of entries, straightforward scaling, no need for rich data structures, and a cache that can be completely rebuilt from scratch — Memcached's simplicity is a feature, not a limitation. If all you need is &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;SET&lt;/code&gt;, and &lt;code&gt;DELETE&lt;/code&gt;, there's no reason to carry the operational weight of streams, sorted sets, pub/sub, and persistence you'll never use.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. A practical comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Redis&lt;/th&gt;
&lt;th&gt;Memcached&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Basic key-value caching&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-memory performance&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rich data structures&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence options&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Primarily cache-oriented&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Counters&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sorted sets&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streams / pub-sub&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational simplicity&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;td&gt;Very good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache-only workloads&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Don't turn this into a checklist exercise — the right choice depends on your workload, not on how many rows favor one column.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. A real-world decision
&lt;/h2&gt;

&lt;p&gt;Say you're designing a simple product catalog service: &lt;code&gt;GET product:123&lt;/code&gt;, returning a small JSON object that changes occasionally. No queues, no streams, no leaderboards, no counters, no persistence requirement. Either Redis or Memcached would work fine here — there's no reason to reach for Redis just because it has more features sitting unused.&lt;/p&gt;

&lt;p&gt;Now imagine the same platform also needs rate limiting, session storage, counters, leaderboards, and some distributed coordination. Redis becomes much more compelling, because now you actually need the capabilities it provides beyond caching.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. Don't ask "which is better?"
&lt;/h2&gt;

&lt;p&gt;This is the biggest lesson in this comparison. Don't ask &lt;em&gt;"Redis vs. Memcached, which is better?"&lt;/em&gt; Ask &lt;strong&gt;what capabilities does my system actually need?&lt;/strong&gt; A simple cache points toward Memcached being enough. A richer in-memory platform points toward Redis being the better fit. And sometimes the honest answer is neither — a managed cloud caching service, a CDN, a database-level cache, or an application-local cache might solve the actual problem better.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqoqkky6z9f22atixwoot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqoqkky6z9f22atixwoot.png" alt="Which one fits: a system that only needs GET, SET, and DELETE on temporary data points to Memcached, while a system that needs counters, sorted sets, streams, pub/sub, sessions, and persistence points to Redis — and sometimes neither is the right answer" width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Technology should follow the requirement, not the other way around.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. The architect's checklist
&lt;/h2&gt;

&lt;p&gt;Six questions worth running through before committing to either: What's the &lt;strong&gt;data model&lt;/strong&gt; — plain key-value, or something richer? What's the &lt;strong&gt;durability&lt;/strong&gt; requirement — can this data simply disappear, or does it need a persistence strategy? What's the &lt;strong&gt;availability&lt;/strong&gt; story — what happens to the application when the cache is unreachable? What's the expected &lt;strong&gt;scale&lt;/strong&gt; — keys, requests per second, total data size? Who owns the &lt;strong&gt;operational complexity&lt;/strong&gt; — monitoring, patching, scaling, recovery, troubleshooting? And most importantly, what's the &lt;strong&gt;failure behavior&lt;/strong&gt; — what does the application actually do when the cache is gone?&lt;/p&gt;

&lt;p&gt;That last question usually matters more than which cache wins a benchmark.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bigger lesson
&lt;/h2&gt;

&lt;p&gt;Redis and Memcached are both excellent technologies, and the interesting difference isn't "newer vs. older" or "better vs. worse." It's &lt;strong&gt;focused caching vs. caching plus a broader in-memory toolbox&lt;/strong&gt; — and there's a wider principle underneath that distinction: don't introduce complexity unless you actually need the capability that comes with it. A simple cache that does exactly what you need can beat a powerful platform your team doesn't need yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;We've now covered why caching exists, how it works, where it lives, the patterns for using it, how to keep it honest, and two of the most common technologies that implement it.&lt;/p&gt;

&lt;p&gt;But we still haven't answered the question this post kept circling back to: &lt;strong&gt;what actually happens to your system when the cache goes down?&lt;/strong&gt; That's next.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you've run both in production, what tipped the decision for you — was it the data model, or was it really about who was going to operate it at 2am?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>redis</category>
      <category>systemdesign</category>
      <category>backend</category>
    </item>
    <item>
      <title>The Hardest Problem in Caching: Invalidation</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Mon, 17 Aug 2026 01:28:56 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/the-hardest-problem-in-caching-invalidation-2kb7</link>
      <guid>https://dev.to/muralidharan_lakshmanan/the-hardest-problem-in-caching-invalidation-2kb7</guid>
      <description>&lt;p&gt;When we started this series, caching sounded simple: put the data somewhere faster, get a faster response. But there's a question we haven't fully answered yet.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What happens when the data in the database changes?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Say the database has &lt;code&gt;product:123&lt;/code&gt; at &lt;code&gt;name = "Laptop"&lt;/code&gt;, &lt;code&gt;price = $999&lt;/code&gt;. We put it in the cache. Everything works perfectly. Then the business changes the price to &lt;code&gt;$899&lt;/code&gt; — but the cache still has &lt;code&gt;$999&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdqfc2f4bbkyhxy1eyiy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdqfc2f4bbkyhxy1eyiy.png" alt="Once you cache it, you have two versions of reality: the database says price = $899 and is the source of truth, while the cache still says price = $999, a copy made earlier that nothing has told about the change" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is stale data, and it leads us to one of the most famous lines in software engineering — often attributed to Phil Karlton:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"There are only two hard things in Computer Science: cache invalidation and naming things."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reason it's hard is simple: a cache creates another copy of your data, and once you have multiple copies, keeping them synchronized becomes a problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. What does "invalidation" actually mean?
&lt;/h2&gt;

&lt;p&gt;Cache invalidation simply means making sure an old cached value is no longer used. There are several ways to do it: delete the cached value and let the next request reload it, update the cached value directly, wait for the TTL to expire it, or invalidate it in response to a published event. Each has trade-offs. Let's look at them.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Strategy #1 — TTL
&lt;/h2&gt;

&lt;p&gt;The simplest solution is to not worry about invalidation at all: just let the cache expire.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;product:123
TTL = 10 minutes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After ten minutes, the entry expires and the next request becomes a miss that repopulates it from the database. TTL is popular because it's simple — you don't need to know exactly when the database changes. The cache eventually becomes invalid on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. TTL is a trade-off
&lt;/h2&gt;

&lt;p&gt;We covered this in Part 2, so briefly: a short TTL gives fresher data at the cost of more misses and more database traffic. A long TTL gives better cache efficiency at the cost of potentially stale data.&lt;/p&gt;

&lt;p&gt;So the useful question was never &lt;em&gt;"what TTL should I use?"&lt;/em&gt; It's &lt;strong&gt;"how stale can this data safely be?"&lt;/strong&gt; — a much more concrete engineering question, and one we'll come back to near the end of this post.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Strategy #2 — explicit invalidation
&lt;/h2&gt;

&lt;p&gt;Instead of waiting for the cache to expire, we can invalidate it the moment the data changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UPDATE database
       ↓
DELETE product:123 from cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next request becomes a cache miss, reads &lt;code&gt;$899&lt;/code&gt; from the database, and repopulates the cache.&lt;/p&gt;

&lt;p&gt;Why delete rather than update in place? Sometimes updating is fine, but deleting is often simpler — the next read just goes and gets the authoritative value, so fewer things need to stay in sync. That gives us &lt;code&gt;Write → Database → Delete cache&lt;/code&gt; rather than &lt;code&gt;Write → Database → Update cache&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. The dual-write problem
&lt;/h2&gt;

&lt;p&gt;Here's the catch: you're now writing to two systems, and either one can fail independently of the other.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpzfaxhlqdpb2pkgfxvu3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpzfaxhlqdpb2pkgfxvu3.png" alt="The dual-write problem: updating the database then deleting the cache leaves the cache serving a stale value if the delete fails, while updating the cache then the database leaves the cache ahead of the source of truth if the database write fails" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you update the database first and the cache delete fails, the cache keeps serving &lt;code&gt;$999&lt;/code&gt; until the TTL eventually clears it — the database is correct, the cache is just temporarily behind. If you update the cache first and the database write fails, the cache is now ahead of the source of truth, showing a value the database never actually confirmed.&lt;/p&gt;

&lt;p&gt;Whenever you're writing to two systems independently, you need to think through what happens if one succeeds and the other doesn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Strategy #3 — update the cache directly
&lt;/h2&gt;

&lt;p&gt;Another approach updates both values in the same operation: &lt;code&gt;Database: $999 → $899&lt;/code&gt; and &lt;code&gt;Cache: $999 → $899&lt;/code&gt;, avoiding the temporary miss entirely. The next request immediately sees &lt;code&gt;$899&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cost is coordination. The application has to make sure the cache update actually happens — and if it fails, do you retry, log it, send an event, or fall back to just deleting the entry instead? The more sophisticated the strategy, the more failure scenarios you need to have an answer for.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Strategy #4 — event-driven invalidation
&lt;/h2&gt;

&lt;p&gt;Instead of every component directly coordinating the cache update, the system can publish an event and let interested consumers react to it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb1dyahfh02tbd49ilfbt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb1dyahfh02tbd49ilfbt.png" alt="Event-driven invalidation with TTL as a safety net: the product service publishes a ProductPriceChanged event to a bus that fans out to cache invalidation, search index updates, and analytics, and if the event is lost the TTL still expires the stale entry eventually" width="800" height="427"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The product service doesn't need to know every consumer that cares about the change — cache invalidation, search indexing, and analytics can all react independently. That's a more loosely coupled architecture. The price is another distributed component: the event system itself, and events can fail, be delayed, be duplicated, or arrive out of order.&lt;/p&gt;

&lt;p&gt;If an invalidation event is lost, the cache consumer never fires and the stale entry just stays — which is exactly why production systems often combine event-driven invalidation with a reasonable TTL. The event gives fast invalidation; the TTL is the safety net that cleans up if the event never arrives.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Cache-Aside + invalidation
&lt;/h2&gt;

&lt;p&gt;Connecting this back to Part 4: if you're using Cache-Aside, the read path checks the cache and falls back to the database on a miss, and the write path is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;updateProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the database, delete the cache entry, let the next read rebuild it. This is one of the simplest practical caching designs, and it's often easier to reason about than trying to manually keep every cached representation in sync.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. But what if multiple caches exist?
&lt;/h2&gt;

&lt;p&gt;Recall Part 3: a request can pass through a browser cache, a CDN, a local application cache, and a distributed cache before it ever reaches the database. Now the product price changes — which of those do you invalidate?&lt;/p&gt;

&lt;p&gt;It's no longer &lt;code&gt;DELETE key from Redis&lt;/code&gt;. It's an architectural question, and it's one reason systems try to avoid caching the same mutable data at too many layers: every additional copy is another invalidation problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. The cache invalidation race condition
&lt;/h2&gt;

&lt;p&gt;Here's a subtler problem. Suppose two requests happen at almost the same time: Request A is updating the price, and Request B is reading it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82wiziqgvvqi9gbxqo1q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F82wiziqgvvqi9gbxqo1q.png" alt="How a race condition re-stales the cache: Request B reads the old price before Request A's update lands, Request A updates the database and deletes the already-empty cache, and then Request B's delayed write puts the stale value back into the cache, wrong until the TTL clears it" width="800" height="477"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The delete itself wasn't wrong — it happened exactly when it should have. The problem is timing: Request B had already read the old value before Request A's write landed, and Request B's write to the cache arrived &lt;em&gt;after&lt;/em&gt; the delete. The stale value gets written back in, and now the cache is wrong until the TTL expires it.&lt;/p&gt;

&lt;p&gt;This is why cache consistency isn't just a matter of adding a &lt;code&gt;cache.delete()&lt;/code&gt; call. Concurrency and ordering matter too.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Invalidation vs. refresh
&lt;/h2&gt;

&lt;p&gt;These are related but different. &lt;strong&gt;Invalidation&lt;/strong&gt; removes the value — &lt;code&gt;DELETE&lt;/code&gt;, then let the normal read path rebuild it. &lt;strong&gt;Refresh&lt;/strong&gt; replaces the old value directly with the new one, avoiding a cache miss but requiring the system to know how to fetch and apply the latest value. Invalidation is simpler; refresh can be faster for the next reader. Which one fits depends on how expensive a miss is for that particular key.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. What about stale-while-revalidate?
&lt;/h2&gt;

&lt;p&gt;A cleverer option: if the cached value is technically expired, serve it anyway while refreshing it in the background, rather than making the user wait on a database call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User → Cache → serve the slightly stale value
                       ↓
                background refresh → Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can give excellent user-facing latency. But it's deliberately serving stale data on purpose, so it only works where the business can tolerate that — which brings us to the real question underneath all of this.&lt;/p&gt;




&lt;h2&gt;
  
  
  13. The real question: how fresh is fresh enough?
&lt;/h2&gt;

&lt;p&gt;Consider three systems. On a &lt;strong&gt;blog&lt;/strong&gt;, if a reader sees a just-published article's old version for 30 seconds, that's almost certainly fine. On an &lt;strong&gt;e-commerce site&lt;/strong&gt;, a customer seeing a stale price for 30 seconds might be acceptable, or might be a real business problem, depending on context. On a &lt;strong&gt;financial system&lt;/strong&gt;, a user seeing yesterday's account balance is unacceptable for most operations.&lt;/p&gt;

&lt;p&gt;The same caching technique cannot be blindly applied everywhere. This is the question every strategy in this post is really answering, just from a different angle.&lt;/p&gt;




&lt;h2&gt;
  
  
  14. A practical starting point
&lt;/h2&gt;

&lt;p&gt;For many backend systems, a reasonable default looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WRITE                          READ
  ↓                              ↓
Database                      Cache
  ↓                          /      \
Invalidate cache          HIT        MISS
  ↓                         ↓          ↓
Set a TTL too            Return    Database
                                       ↓
                                 Populate cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fast reads, relatively simple invalidation, TTL as a safety mechanism, and the database as the one source of truth. It isn't perfect — but it's understandable, and understandability is a real production advantage.&lt;/p&gt;




&lt;h2&gt;
  
  
  15. Six rules worth keeping in mind
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Know your source of truth.&lt;/strong&gt; Usually the database is authoritative and the cache is a temporary copy — don't accidentally let the cache become the source of truth unless your architecture explicitly calls for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define acceptable staleness deliberately.&lt;/strong&gt; Don't pick a TTL at random; ask how long this specific data can safely be wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer simple invalidation when it's enough.&lt;/strong&gt; &lt;code&gt;UPDATE DB, DELETE cache&lt;/code&gt; beats a complicated synchronization mechanism more often than it seems like it should.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have a safety net.&lt;/strong&gt; TTL keeps protecting you even when explicit invalidation fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Think about failures explicitly.&lt;/strong&gt; What happens if the database succeeds but the cache invalidation fails? What happens if the cache is unavailable altogether?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't create unnecessary copies.&lt;/strong&gt; Every additional caching layer is another invalidation problem, not just another performance win.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The bigger lesson
&lt;/h2&gt;

&lt;p&gt;Caching is usually introduced as a performance optimization. But the moment you start caching mutable data, you're actually managing multiple copies of reality — which means thinking about performance, freshness, consistency, failure handling, and operational complexity all at once.&lt;/p&gt;

&lt;p&gt;The fastest cache isn't necessarily the best one. A cache that's always perfectly fresh may be too expensive to run. A cache that's extremely cheap may serve data that's too stale to trust. The engineering work is finding the right balance for each piece of data — not once, but deliberately, the way we did with the product example back in Part 4.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;We've now covered why caching exists, how it works, where it lives, the patterns for reading and writing through it, and how to keep it from lying to you.&lt;/p&gt;

&lt;p&gt;Next, we'll get more concrete. There are two names you're almost guaranteed to run into when working with distributed caching: &lt;strong&gt;Redis&lt;/strong&gt; and &lt;strong&gt;Memcached&lt;/strong&gt;. But the interesting question was never &lt;em&gt;"which one is better?"&lt;/em&gt; — it's &lt;strong&gt;"what kind of caching problem are we actually trying to solve?"&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Has a cache invalidation bug ever made it to production on something you built? The delete-vs-update-order kind of bug is usually invisible until exactly the wrong moment — curious what triggered yours.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>systemdesign</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Cache Patterns Every Engineer Should Know</title>
      <dc:creator>Muralidharan Lakshmanan</dc:creator>
      <pubDate>Sat, 15 Aug 2026 22:33:53 +0000</pubDate>
      <link>https://dev.to/muralidharan_lakshmanan/cache-patterns-every-engineer-should-know-ibf</link>
      <guid>https://dev.to/muralidharan_lakshmanan/cache-patterns-every-engineer-should-know-ibf</guid>
      <description>&lt;p&gt;In the previous parts of this series, we answered two questions: why do we need caching, and where should the cache live?&lt;/p&gt;

&lt;p&gt;Now we need a more practical one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How should the application actually interact with the cache?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is where caching patterns come in. A cache is not just a box where we put data. We need a strategy for reading data, handling misses, writing data, updating cached values, and dealing with staleness. Different patterns solve these problems differently. Let's look at the most common ones.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Cache-Aside — the most common pattern
&lt;/h2&gt;

&lt;p&gt;Let's start with &lt;strong&gt;Cache-Aside&lt;/strong&gt;, also called lazy loading. The basic idea: the application is responsible for checking the cache and loading data on a miss.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /products/123

Application → Cache → HIT → Product. Done.

Application → Cache → MISS
                        ↓
                    Database
                        ↓
                  Store in cache
                        ↓
                    Response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:123"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findProduct&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:123"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next request can be served from the cache.&lt;/p&gt;

&lt;p&gt;Cache-Aside is popular because it's simple. The application explicitly decides what to cache, when to cache it, what TTL to use, what to do on a miss, and when to invalidate it. It also works with almost any cache technology.&lt;/p&gt;

&lt;p&gt;The downside is that the application now carries caching logic. Developers need to remember the check-fallback-populate sequence, and if several services do this independently, that logic tends to get duplicated. Still, Cache-Aside is often the best starting point.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Read-Through — let the cache do the loading
&lt;/h2&gt;

&lt;p&gt;Read-Through moves that responsibility away from the application. Instead of the application saying &lt;em&gt;"if the cache misses, I'll query the database,"&lt;/em&gt; it simply says &lt;em&gt;"give me the data."&lt;/em&gt; The cache handles the miss itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1qv920du7o8t5v48zdfc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1qv920du7o8t5v48zdfc.png" alt="Who handles the cache miss: in Cache-Aside the application falls back to the database directly; in Read-Through the cache queries the database on its own and the application only ever talks to the cache" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The application code gets simpler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"product:123"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the cache doesn't have it, the cache layer knows how to retrieve it. The application doesn't need to know as much about the underlying data source, which can produce cleaner application code.&lt;/p&gt;

&lt;p&gt;The catch is that not every cache supports this natively — you need a caching layer or framework that knows how to load the missing data, which means more abstraction and configuration. Read-Through can be elegant, but Cache-Aside is usually easier to understand and implement directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Write-Through — write to the cache and database together
&lt;/h2&gt;

&lt;p&gt;So far we've mostly talked about reads. But what happens when data changes? Suppose a customer updates their address. Now the database and the cache both need to reflect the new value.&lt;/p&gt;

&lt;p&gt;With Write-Through, a write goes through the cache, and the cache updates the underlying data store as part of the same operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Update customer address
          ↓
       Cache
          ↓
      Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main benefit is freshness — the cache is updated as part of the write path, so the system reduces the chance of serving an old value. The cost is that writes become more expensive: instead of &lt;code&gt;Application → Database&lt;/code&gt;, a write now involves &lt;code&gt;Application → Cache → Database&lt;/code&gt;. We're trading write performance and complexity for better cache freshness.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Write-Behind — make writes fast
&lt;/h2&gt;

&lt;p&gt;Now the opposite approach. What if writes are extremely frequent — say, 50,000 updates per second? Writing every one immediately to the database may be expensive.&lt;/p&gt;

&lt;p&gt;With Write-Behind, the cache accepts the update first and the write returns immediately. The database catches up later, asynchronously.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9h73wufpvnwgoslh28fb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9h73wufpvnwgoslh28fb.png" alt="Write-Through vs. Write-Behind: Write-Through only returns once both the cache and database are updated, while Write-Behind returns as soon as the cache is updated and persists to the database later — risking data loss if the cache fails before that happens" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This can make writes extremely fast. But there's a serious trade-off: what happens if the cache crashes before the update reaches the database? Potentially, data loss.&lt;/p&gt;

&lt;p&gt;That's why Write-Behind shouldn't be treated as simply "a faster Write-Through." It's a fundamentally different consistency and durability model. It works best when eventual persistence is acceptable, the cache has reliable durability mechanisms of its own, updates can be replayed or recovered, and extreme write performance genuinely matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Refresh-Ahead — don't wait for the cache to expire
&lt;/h2&gt;

&lt;p&gt;Here's another problem. Suppose &lt;code&gt;product:123&lt;/code&gt; is cached with a TTL of 10 minutes, and thousands of users are requesting it. At minute 9, every request is a hit. At minute 10, the cache expires — and thousands of requests can arrive at the database at the same instant.&lt;/p&gt;

&lt;p&gt;We touched on this earlier in the series. It's commonly called a &lt;strong&gt;cache stampede&lt;/strong&gt; or &lt;strong&gt;thundering herd&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Refresh-Ahead tries to avoid it. Instead of waiting for the value to expire, the system refreshes it shortly beforehand, while the existing cached value keeps serving requests in the meantime.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv4wfagjxt4hxe7rj1tfa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv4wfagjxt4hxe7rj1tfa.png" alt="Refresh-Ahead avoids the thundering herd: without it, every request piles onto the database the instant the TTL expires; with it, a background refresh updates the value before expiry so every request stays a hit" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Imagine a flight-search application where a popular route is requested thousands of times. Instead of letting the cache expire completely, the system refreshes the data once it has, say, 30 seconds left. The next request doesn't have to wait for a database call — it just gets served from an already-fresh cache.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Comparing the patterns
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Who handles the miss?&lt;/th&gt;
&lt;th&gt;How writes work&lt;/th&gt;
&lt;th&gt;Main benefit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cache-Aside&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application&lt;/td&gt;
&lt;td&gt;Application manages writes&lt;/td&gt;
&lt;td&gt;Simple and flexible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Read-Through&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cache&lt;/td&gt;
&lt;td&gt;Depends on implementation&lt;/td&gt;
&lt;td&gt;Cleaner application code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Write-Through&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cache layer&lt;/td&gt;
&lt;td&gt;Cache and database together&lt;/td&gt;
&lt;td&gt;Better freshness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Write-Behind&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cache&lt;/td&gt;
&lt;td&gt;Database updated later&lt;/td&gt;
&lt;td&gt;Very fast writes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Refresh-Ahead&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cache / background process&lt;/td&gt;
&lt;td&gt;Usually a normal write strategy&lt;/td&gt;
&lt;td&gt;Reduces cold misses&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There is no universally "best" pattern. The workload determines the answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. A real-world example
&lt;/h2&gt;

&lt;p&gt;Let's imagine an e-commerce product with a name, description, price, inventory count, and reviews. Should all of it use the same caching pattern? Probably not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv6flxdo7n99wd0v2gnm2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv6flxdo7n99wd0v2gnm2.png" alt="One product, four caching strategies: the description uses plain Cache-Aside with a long TTL, price adds explicit invalidation, inventory uses a short TTL or bypasses the cache, and recommendations combine Cache-Aside with Refresh-Ahead" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;description&lt;/strong&gt; changes rarely, so plain Cache-Aside with a one-hour TTL is fine. The &lt;strong&gt;price&lt;/strong&gt; changes more often, so we pair Cache-Aside with explicit invalidation the moment it changes. &lt;strong&gt;Inventory&lt;/strong&gt; is more sensitive — a stale number could let a customer buy something that's no longer available, so a much shorter TTL, or skipping the cache in some parts of the workflow, makes more sense. &lt;strong&gt;Recommendations&lt;/strong&gt; can be expensive to calculate, which makes them a good candidate for Cache-Aside plus Refresh-Ahead.&lt;/p&gt;

&lt;p&gt;That's an important lesson: &lt;strong&gt;don't choose one caching pattern for your entire system. Choose the pattern based on the behavior of the data.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  8. The hidden complexity: cache invalidation
&lt;/h2&gt;

&lt;p&gt;Suppose the database has &lt;code&gt;product:123&lt;/code&gt; at &lt;code&gt;$899&lt;/code&gt;, but the cache still has &lt;code&gt;$999&lt;/code&gt;. A caching pattern doesn't automatically solve this — you still need to decide what happens to the cache when the database changes.&lt;/p&gt;

&lt;p&gt;The common answers are to delete the entry and let the next request reload it, update the cached value directly, let the TTL expire naturally, or invalidate the cache in response to a published event when the database changes.&lt;/p&gt;

&lt;p&gt;This is a big enough topic that the next part of this series is devoted entirely to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Which pattern should you start with?
&lt;/h2&gt;

&lt;p&gt;If you're designing a new application and aren't sure what to use, don't reach for the most sophisticated pattern first. For many read-heavy applications, plain Cache-Aside in front of the database is an excellent starting point.&lt;/p&gt;

&lt;p&gt;Then measure. Is the hit ratio good? Is database load actually reduced? Are misses expensive? Are hot keys causing problems? Is stale data acceptable? Are writes becoming a bottleneck? Only once you have answers should you introduce something more sophisticated.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. The engineer's mental model
&lt;/h2&gt;

&lt;p&gt;Here's a short way to hold all five patterns in your head at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache-Aside&lt;/strong&gt; — "I'll manage the cache myself."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-Through&lt;/strong&gt; — "The cache will load missing data for me."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write-Through&lt;/strong&gt; — "When I write, update the cache and the source together."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write-Behind&lt;/strong&gt; — "I'll make the cache the fast write point and persist later."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh-Ahead&lt;/strong&gt; — "Don't let popular data go cold if I can refresh it proactively."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once these five ideas are second nature, most caching architectures become much easier to reason about.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;We've now covered why caching exists, how it actually works, where it should live, and the patterns that govern how an application talks to it.&lt;/p&gt;

&lt;p&gt;But there is one caching problem that almost every engineer eventually runs into:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you make cached data disappear when the real data changes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's cache invalidation — often called the hardest problem in caching — and it's next.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Which pattern is running in your production system right now, and did your team choose it deliberately or inherit it? Curious to hear in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>caching</category>
      <category>systemdesign</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
