The fastest way to produce a shallow system design: saying "I'll add Redis for caching" and stopping there. Naming "Redis" without explaining what you're caching and why signals you're naming a tool, not solving a problem.
The real question isn't "would you use Redis?" — it's "which caching pattern fits this access pattern?" Choosing the wrong strategy leads to stale data, cache stampedes, consistency issues, and poor performance, regardless of which underlying technology you picked.
The Right Way to Think About Caching
Never say "for caching." Say something with an actual access pattern attached:
- "User session data, accessed 10K/sec, TTL 30 minutes, cache-aside pattern."
- "Hot product listings — 20% of items get 80% of reads, LRU eviction."
- "Computed aggregates, refreshed every 5 minutes, write-behind to reduce DB load."
Each of these names: what's being cached, how hot it is, how long it should live, and which read/write pattern fits it. That's the difference between reciting a buzzword and demonstrating real design judgment.
The rule: access pattern first, technology second — always.
The 8 Patterns
1️⃣ Cache-Aside (Lazy Loading)
The application (not the cache) is responsible for the loading logic — the app makes two separate calls, one to the cache and (on a miss) a second one to the DB, and the app itself writes the result back into the cache.
App checks cache first
├─ HIT → return directly
└─ MISS → App queries DB directly → App writes result into cache → return
// Cache-aside, Java — the branching ("check, then fetch, then populate") lives in the app
User user = cache.getIfPresent(userId);
if (user == null) { // MISS — app itself talks to the DB
user = userRepository.findById(userId);
cache.put(userId, user); // app itself populates the cache
}
return user;
Best for: read-heavy workloads, frequently accessed data, and applications that want explicit control over caching logic (what gets cached, when, and how it's keyed).
Trade-off: stale data is possible between writes — if the DB row changes but the cached copy isn't invalidated, reads serve outdated data until TTL expiry or explicit invalidation. This app-level branching logic also has to be duplicated at every call site that reads this data, unless it's wrapped in a shared helper.
2️⃣ Read-Through
Similar outcome to cache-aside, but the loading logic lives inside the cache layer itself rather than the application. Critically, the app makes only one outbound call, ever — it never bounces back to the DB itself. On a miss, the cache's own configured loader (registered once, at setup) fetches from the DB, stores the result, and returns it, all inside that single call.
App → Cache (ONE call, period)
Cache internally decides:
├─ HIT → return from cache
└─ MISS → cache's own loader fetches from DB → cache stores it → cache returns it
App never talks to the DB directly, in either the hit or miss case.
// Read-through, Java (Caffeine) — the DB-fetch logic is registered ONCE, not per call-site
LoadingCache<String, User> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.build(key -> userRepository.findById(key)); // "loader" — lives inside the cache, not the app
// Every call site, hit or miss, looks identical — no branching logic in app code at all:
User user = cache.get(userId);
Benefits: simpler application code (no manual "check cache, then DB, then populate" boilerplate repeated everywhere), centralized caching logic, easier maintenance since the fetch-on-miss behavior lives in one place (the loader) instead of being duplicated across every call site.
Where does Bloom filter / Cache Penetration protection go here? Since the app only ever makes one call, a Bloom filter check has to sit inside that single call's miss path, so it can block the loader from ever reaching the DB for an invalid key — either as a guard right before cache.get() in app code, or (cleaner) pushed one layer deeper, directly inside the loader function itself:
LoadingCache<String, User> cache = Caffeine.newBuilder()
.build(key -> {
if (!bloomFilter.mightContain(key)) return null; // definitely invalid — skip DB entirely
return userRepository.findById(key);
});
Cache-Aside vs. Read-Through, in one line: in cache-aside the app owns the "check → miss → fetch → populate" sequence (two calls out); in read-through the cache owns that entire sequence behind a single call, with the DB-fetch logic registered once as a loader instead of repeated per call-site.
3️⃣ Write-Through
Every write goes to the cache first, then synchronously to the database, before the write is considered complete.
Benefits: strong cache consistency — the cache always contains the latest data, since a write never returns success without also having landed in the DB.
Trade-off: higher write latency, since every write pays the cost of two synchronous operations instead of one.
4️⃣ Write-Back (Write-Behind)
Write to the cache immediately and return; the database update happens asynchronously afterward.
Benefits: extremely fast writes (the caller doesn't wait on the DB), reduced database load (writes can be batched/coalesced before flushing).
Trade-off: potential data loss if the process crashes after the cache write but before the async DB flush completes.
5️⃣ Write-Around
Writes bypass the cache entirely and go directly to the database. The cache is only populated later, when that data happens to be read (effectively falling back to cache-aside on the read path).
Best for: write-heavy systems, and data that's written often but rarely read — e.g., logs, audit trails, bulk-ingested records. Helps avoid cache pollution: without write-around, every write would occupy cache space for data that may never actually be read again, evicting genuinely hot data to make room for it.
6️⃣ Refresh-Ahead
Popular ("hot") data is proactively refreshed before it expires, rather than waiting for a request to miss and trigger a reload.
Benefits: lower cache miss rate, consistently low latency (users never hit the "cold" path for data the system already predicted would still be needed), better overall experience.
Commonly used for: product catalogs, trending content, dashboard data — anything with predictable, sustained popularity where "refresh proactively" is cheaper than "let it expire and pay for a stampede of misses" (see pattern #7).
7️⃣ Cache Stampede Protection (Thundering Herd)
What happens when a hot cache key expires and thousands of requests hit the database simultaneously? That's a cache stampede (also called "Cache Breakdown" — see "The 3 Cache Killers" section below for how this compares to Cache Penetration and Cache Avalanche).
Key expires → 10,000 requests miss at the same moment → 10,000 identical queries hit the DB
Common solutions:
- Distributed locks — only one process is allowed to recompute/refill the key; others wait.
- Request coalescing (single-flight) — concurrent requests for the same missing key share one in-flight recomputation instead of each triggering their own.
- Staggered expiration times (jitter) — add randomness to TTLs so a large batch of keys set at the same time doesn't all expire in the same instant.
8️⃣ Distributed Cache
Multiple application instances share a centralized cache rather than each maintaining its own local, inconsistent in-process cache.
Benefits: horizontal scalability (add more app instances without duplicating cache state per instance), shared cached data (one instance's cache-fill benefits every other instance), consistent performance across nodes (no "cold" instance behind a load balancer that hasn't warmed its own local cache yet).
Examples: Redis, Memcached, Hazelcast.
The 3 Cache Killers — How Redis Crashes Your Database
Caching looks simple on paper: check cache → hit, return; miss → fetch from DB, populate cache. This works fine until traffic hits scale. At scale, databases rarely die from bugs — they die from cache failures. These are the three named failure modes worth knowing cold:
1. Cache Penetration
Problem: Users repeatedly request data that doesn't exist in either the cache or the DB (e.g., userId = -1, or a scraper probing random IDs). Since the data doesn't exist, it can never be cached — so every request is a guaranteed cache miss and a guaranteed DB miss, forever. If this is automated/attacked at volume, it's effectively an unthrottled DB query flood with no cache layer able to absorb any of it.
Fixes:
- Bloom filter in front of the cache/DB — a probabilistic structure that can definitively say "this key does not exist" before ever touching the DB, blocking invalid keys cheaply.
-
Cache the empty/negative result for a short TTL (e.g.,
nullsentinel with 30-60s TTL) — so repeated requests for the same nonexistent key hit the cache (returning "not found") instead of re-querying the DB every time.
2. Cache Breakdown (Hot Key Expiry) — same as "Cache Stampede," pattern #7 above
Problem: A single extremely popular key (a viral profile, a trending product) is read by millions of requests. The instant that one key expires, all of those requests miss simultaneously and hit the DB together — a single hot key becomes a synchronized flood.
Fixes: (see pattern #7 for full detail) mutex/distributed lock so only one request recomputes the value while others wait, or logical/soft expiration — serve the stale value immediately while refreshing it in the background, so no caller ever waits on a synchronous DB hit.
3. Cache Avalanche
Problem: Many different keys expire at (approximately) the same moment — e.g., they were all set with the same TTL at the same time, or the cache server/cluster restarts and loses everything at once. Unlike breakdown (one hot key), this is a mass, simultaneous expiry across the whole keyset, flooding the DB with a broad wave of misses all at once.
Fixes:
-
TTL jitter — randomize expiry times (e.g.,
baseTTL + random(0, 60s)) so a batch of keys set together doesn't all die in the same instant. - For the "cache cluster restart" variant: warm the cache before routing traffic to it, and/or run the cache in a replicated/HA setup so a single node restart doesn't wipe the whole dataset.
Quick Comparison
| Killer | Trigger | Scope | Primary Fix |
|---|---|---|---|
| Penetration | Requests for data that never existed | Any single (invalid) key, repeatedly | Bloom filter + cache negative results |
| Breakdown / Stampede | One hot key expires | One key, many concurrent requests | Mutex lock or logical/soft expiration |
| Avalanche | Many keys expire together (or cache restarts) | Whole keyset at once | TTL jitter + cache warming/HA |
The meta-lesson: good caching design isn't about the happy path — it's about explicitly naming what breaks at scale and how you'd catch it before it takes down the database, not after.
Eviction Policies
Independent of which pattern above you use, once the cache is full, something has to decide what gets removed to make room:
- LRU (Least Recently Used) — the default, works for most cases. Evicts whatever hasn't been accessed in the longest time.
- LFU (Least Frequently Used) — better when access patterns are stable over time (the same items stay popular consistently). Evicts based on access count, not recency.
Example: for a product catalog, LFU tends to be the better fit. Popular products get accessed consistently over long periods — a bestseller shouldn't get evicted just because there was a brief lull in requests for it, which is exactly the failure mode LRU is prone to for this access pattern.
Key Questions a Caching Design Should Answer
- Cache-Aside vs. Read-Through — when would you choose each? Cache-aside when you want explicit application-level control over what/how to cache (e.g., custom key composition, partial object caching); read-through when you want simpler, centralized application code and are fine with the cache layer's built-in loader behavior.
- How do you handle stale data? Short, workload-appropriate TTLs; explicit invalidation on write (delete-or-update the cache entry when the source-of-truth changes); versioned cache keys; accept and bound staleness explicitly where it's tolerable (e.g., "eventually consistent within 30 seconds" for a dashboard) rather than pretending the cache is always perfectly fresh.
- What is a cache stampede? See pattern #7 above — a hot key's expiry causing a burst of simultaneous cache misses that all hit the database at once.
- How would you design cache invalidation? Options: TTL-based (simplest, bounded staleness), event-driven invalidation (the write path explicitly deletes/updates the affected cache key(s), e.g., via a CDC stream or direct call), or versioned/keyed-by-content approaches (the cache key itself encodes a version, so an old version simply stops being requested rather than needing active eviction). "There are only two hard things in computer science: cache invalidation and naming things" — worth acknowledging this is a genuinely hard problem, not a solved one.
- How do you maintain cache consistency? Pick the pattern that matches your consistency needs: write-through for strong consistency (slower writes), write-behind/cache-aside for eventual consistency (faster, but a staleness window exists) — and be explicit about which one you're trading for which.
- Redis vs. Memcached? Redis supports richer data structures (lists, sets, sorted sets, hashes, streams), persistence (AOF/RDB), replication, and pub/sub — useful when the cache needs to do more than store flat key-value pairs. Memcached is simpler, purely in-memory, multi-threaded per instance, and can have a lower per-operation overhead for pure key-value caching at very high throughput when none of Redis's extra features are needed.
- How would you cache data for millions of users? Use a distributed cache (pattern #8) with consistent hashing to shard keys across cache nodes, size the cluster based on hot working-set size (not total data size), apply an eviction policy suited to the access pattern, and add stampede protection for any keys shared across large numbers of users (e.g., a global config or a trending list) since those are exactly the keys most likely to cause a thundering herd.
Real-World Example: Talkdesk's Dual-Store Architecture
At Talkdesk, a dual-store architecture was used: DynamoDB for key-value storage, Elasticsearch for full-text search, and Redis as the hot-data cache layer sitting in front of both. The point: the access pattern drove every technology choice — DynamoDB for point-lookup-style key-value access, Elasticsearch for flexible/full-text query needs, and Redis specifically for the subset of data that's read disproportionately often and can tolerate the cache's consistency/eviction trade-offs.
The Rule
Access pattern first. Technology second. Always.
Naming "Redis" is not a design decision — it's a detail that falls out of already having answered: what is being read/written, how often, how stale can it be, and what happens when it's evicted or expires? Answer those first, and the caching strategy (which of the 8 patterns above, which eviction policy, TTL length, stampede protection) follows naturally and demonstrates real design judgment.
Top comments (0)