Design a Distributed Cache
Redis-style In-Memory Cache System Design
Overview
A distributed cache keeps a working set of data in memory, spread across many servers, so that repeated reads are fast. The classic use: a popular query's result — say the profile of a celebrity, or the rendered top-of-feed — is expensive to compute from the database, so you compute it once, store it in the cache, and serve the next million reads from memory in under a millisecond instead of hammering the database. Redis and Memcached are the well-known implementations.
It is labeled "Medium" because the single-server version is trivial (a hash map with a size limit), but the moment the data no longer fits on one machine, three hard problems appear at once. First, you must spread keys across many nodes so that adding or removing a node doesn't scramble everything — this is where consistent hashing comes in. Second, a node will die, so each shard needs a replica and a story for what happens to its keys when it's lost. Third, memory is finite, so the cache must evict old keys and stay roughly in sync with the database it caches — without which you serve stale data.
The single most important framing, which we'll return to throughout: the cache is a derived, rebuildable copy of a durable store — never the source of truth. That single decision is what makes almost every hard tradeoff easy. A lost node, a dropped write, a stale replica read — all of them are acceptable because the real data still lives safely in the database, and the cache can always be refilled from it.
In this walkthrough you'll scope the system, size it, pick the data model, design the client contract, draw the read and write paths, then go deep on sharding with consistent hashing, replication and failure handling, and eviction plus keeping the cache consistent with the source of truth.
Out of scope (say so): this is the in-memory, best-effort cache, not a durable database. A durable, quorum-replicated key-value store (Dynamo-style, with quorum reads/writes and the full CAP tradeoff) is the sibling problem — we mention where it differs but do not design it here.
Functional requirements
- Get. Given a key, return its value if present in the cache (a hit), or signal absence (a miss) so the caller can load from the database.
- Set. Store a key → value pair, optionally with a TTL (time-to-live) after which it expires automatically.
- Delete. Remove a key — used to invalidate an entry after the underlying data changes in the database.
- Route. The client (or a proxy) must find which node holds a given key, without a central directory on the hot path.
- Scale out. Adding or removing a node must move only a small fraction of keys, not the whole keyspace.
Out of scope (state it): durability guarantees, transactions across keys, and complex server-side data structures. This system stores simple key → value entries in memory and serves them fast; it is not a system of record.
Non-functional requirements
- Very low latency. A cache exists to be fast — sub-millisecond in-memory reads, p99 in the low single-digit milliseconds including the network hop. If a cache read is slow, it has no reason to exist.
- High throughput. The cache must absorb far more reads than the database could — hundreds of thousands to millions of reads/sec across the cluster.
- Horizontally scalable. The working set outgrows one machine's RAM, so keys must spread across many nodes, and the cluster must grow by adding nodes.
- Available over strongly consistent. A cache is typically AP / best-effort: on failure it should degrade to a miss (fall back to the database), not return an error. A replica read may be slightly stale, and a lost node may drop not-yet-replicated writes — acceptable, because the durable store is the source of truth.
- Bounded memory. Each node has a hard memory cap; the cache holds a working set, not everything, and evicts under pressure.
Important
"Available over strongly consistent" is the defining choice. Because the cache is a rebuildable copy of a durable store, you trade consistency for speed and availability everywhere. If the interview instead demands a durable, strongly-consistent store, you are no longer designing a cache — that's the quorum key-value store, a different problem.
Estimations
State assumptions; the goal is to justify how many nodes you need and how the working set fits in RAM, not to be exact.
Rounded assumptions
- Read QPS: ~1M reads/sec across the cluster (the whole point is to shield the database from this).
- Write/Set QPS: ~100K sets/sec — an order of magnitude below reads, since a cache is read-heavy by design.
- Working set: ~1 TB. Suppose ~1 billion hot entries at ~1 KB each (a serialized object, a rendered fragment). That's ~1 TB of values that must live in RAM. This is the number that decides the node count.
- RAM per node: ~64 GB usable for cache data (a typical cache instance).
- Node count: ~1 TB ÷ ~64 GB ≈ ~16 nodes for the data, before replication. With one replica each, ~32 nodes. Round up for headroom and hot spots.
- Hot keys. Traffic is not uniform — a handful of keys (a viral celebrity's profile) may each take tens of thousands of reads/sec, far more than one node can serve for a single key. Hot keys need special handling beyond plain hashing.
How the numbers affect the design
| Signal from the numbers | Design decision |
|---|---|
| ~1 TB working set ≫ one machine's RAM | Shard the keyspace across ~16+ nodes; no single node holds everything. |
| Nodes will be added/removed as the set grows | Route with consistent hashing, not hash(key) % N, so a membership change moves only ~1/N of keys. |
| A node will die | Each shard needs a replica and an automatic failover story. |
| A few keys take tens of thousands of reads/sec | Hot keys can't be split by hashing (one key → one node) — replicate the hot key or cache it client-side. |
| Bounded RAM, working set only | Evict under a memory cap (LRU/LFU) and rely on TTL to expire cold or stale entries. |
| Reads ≫ writes; staleness tolerable | Cache-aside + TTL + explicit invalidation is enough; no need for strong consistency. |
Warning
Do not size the cache to hold all your data. A cache holds the working set — the small fraction of keys actually being read right now. Sizing it as a full copy of the database wastes enormous RAM and misses the point: the database still holds everything, the cache holds only what's hot.
Core entities / data model
Two things exist: the cache entry (the key → value pair a node stores in memory) and the ring / shard map (how the cluster decides which node owns a key).
| Entity | Key fields |
|---|---|
CacheEntry |
key (string) → value (bytes), plus expires_at (from TTL) and eviction metadata (e.g. last-access time / access count) |
RingMap |
The mapping from hash positions → nodes: the set of live nodes and their virtual node positions on the hash ring |
The cache entry. On each node, entries live in an in-memory hash map for O(1) lookup by key. Each entry carries its value plus a little metadata: an expires_at timestamp so a TTL'd entry can be dropped, and eviction bookkeeping (recency or frequency) so the node knows which entries to discard first when it hits its memory cap (deep dive 3).
The ring / shard map. The cluster's routing table: which nodes are alive and where each sits on the hash ring (deep dive 1). It changes only when a node joins or leaves — rarely, compared to the millions of reads/sec — so it can be distributed to all clients/proxies and cached, then updated on membership change. It is small (a list of nodes and their virtual-node positions), not per-key.
Caution
The cache entry is not the source of truth. It is a copy of a value that lives durably in a database. Never design a flow where losing a cache entry loses data — every value in the cache must be recomputable or reloadable from the durable store. If you find yourself needing the cache to never lose a write, you've stopped designing a cache.
Client contract
A cache exposes a small command interface, not REST endpoints — a client library (or a proxy) speaks it. The three operations plus routing are the whole contract.
Get a key
GET <key>
Returns: value bytes, or a MISS sentinel if absent/expired
Look the key up in memory on the node that owns it. A hit returns the value in well under a millisecond. A miss (absent, or expired past its TTL) tells the caller to load from the database and (usually) SET it back — the cache-aside pattern (deep dive 3). The client does not error on a miss; a miss is normal and expected.
Set a key (optionally with TTL)
SET <key> <value> [TTL <seconds>]
Returns: OK
Store or overwrite the key → value pair on its owning node. The optional TTL sets expires_at = now + seconds, after which the entry is treated as absent. TTL is the safety net for staleness: even if you forget to invalidate an entry after a database write, it self-corrects when it expires (deep dive 3).
Delete a key
DELETE <key>
Returns: OK (whether or not the key was present)
Remove the key from its owning node. The primary use is invalidation: after the underlying row changes in the database, delete the cached copy so the next read misses and reloads the fresh value. Idempotent — deleting an absent key is a harmless no-op.
How the client finds the node
The client (or a proxy in front of the cluster) holds the ring map and computes the owning node itself — no central lookup on the hot path:
node = ring.locate(hash(key)) // walk the ring to the owning node
send GET/SET/DELETE to node
ring.locate is deep dive 1. The key insight: routing is a local computation from a cached ring map, so a GET is one hash plus one network hop — there is no directory server to consult per request.
High-level architecture
Two flows matter: the read path (get, with hit vs miss), and the write path (set / invalidate). We'll walk each, then combine them into the cluster with client-side routing.
1. Read path (hit vs miss)
The read path is the common case, and its whole job is to answer from memory when it can, and fall back to the database when it can't.
flowchart LR
App["Application"] -->|"1. GET key"| Node["Cache Node<br/>(owner of key)"]
Node -->|"2a. HIT: value"| App
Node -.->|"2b. MISS"| App
App -.->|"3. load on miss"| DB[("Database<br/>source of truth")]
App -.->|"4. SET key = value (TTL)"| Node
- The application computes the owning node from the ring and sends
GET keyto it. - Hit: the node has the entry (and it hasn't expired) → returns the value in under a millisecond. This is the fast path the cache exists for.
- Miss: the entry is absent or expired → the node returns a MISS, and the application loads the value from the database (the source of truth).
- The application writes the freshly loaded value back with
SET key = valueand a TTL, so the next read hits. This lazy-load-on-miss is cache-aside (deep dive 3).
Tip
A miss is not an error — it is the normal way cold data enters the cache. Design the caller to always have a database fallback, so a miss (or an unreachable node) degrades to a slightly slower read, never a failure.
2. Write path (set / invalidate)
Writes are rarer than reads. The important write is not "put data in the cache" but "keep the cache from serving stale data after the database changes."
flowchart LR
App["Application"] -->|"1. write row"| DB[("Database<br/>source of truth")]
App -->|"2. DELETE key (invalidate)"| Node["Cache Node<br/>(owner of key)"]
Node -.->|"3. next GET → MISS → reload fresh"| App
- The application writes the new value to the database first — the durable store is always updated as the source of truth.
- It then invalidates the cached copy:
DELETE keyon the owning node (rather than trying to overwrite it withSET, which is easy to get wrong under concurrency — deep dive 3). - The next
GETmisses and reloads the fresh value from the database via cache-aside. The stale copy is gone; the reader sees the new value.
The rule: write the database, then invalidate the cache. TTL is the backstop — even if the invalidation is lost, the stale entry expires on its own.
3. Combined architecture (cluster with client-side routing)
Putting it together: many cache nodes each own a slice of the keyspace via the hash ring; clients route to the owning node themselves using a cached ring map; each shard has a replica; and the database sits behind as the source of truth that every miss falls back to.
flowchart TB
subgraph Clients["Application servers (each holds the ring map)"]
C1["App server<br/>ring.locate(key)"]
end
subgraph Cluster["Cache cluster (keyspace split by hash ring)"]
direction LR
S1["Shard A<br/>primary + replica"]
S2["Shard B<br/>primary + replica"]
S3["Shard C<br/>primary + replica"]
end
C1 -->|"GET/SET/DELETE<br/>routed by hash"| S1
C1 --> S2
C1 --> S3
S1 -.->|"miss → reload"| DB[("Database<br/>source of truth")]
S2 -.-> DB
S3 -.-> DB
Coord["Membership / config<br/>(gossip or coordinator)"] -.->|"ring map updates"| C1
- Each shard owns a contiguous slice of the hash ring and is a primary + one or more replicas (deep dive 2).
- App servers hold the ring map and compute the owning shard locally — one hash, one hop, no per-request directory lookup.
- A membership layer (a gossip protocol, or a small coordinator like ZooKeeper/etcd) tracks which nodes are alive and pushes ring-map updates when a node joins or leaves — off the hot path.
- Every miss falls back to the database, and the loaded value is written back into the cache. The database is the source of truth; the cluster is a fast, rebuildable copy of its hot subset.
Deep dives
1. Sharding with consistent hashing (the headline)
The working set is ~1 TB — far more than one node's RAM — so keys must spread across N nodes. The question is which node owns which key, and the answer has to survive nodes being added and removed as the cluster grows or a machine dies. Here are the approaches, worst to best.
Modulo hashing: hash(key) % N (avoid — remaps almost everything when N changes)
Assign each key to node number hash(key) % N, where N is the node count. Simple and perfectly balanced — while N never changes.
Worked example — 4 nodes, then you add a 5th (N goes 4 → 5). A key's node is hash(key) % N, so changing N recomputes almost every key's home:
key "user:42" hash=100 → 100 % 4 = 0 → 100 % 5 = 0 (stayed)
key "user:99" hash=101 → 101 % 4 = 1 → 101 % 5 = 1 (stayed)
key "post:7" hash=102 → 102 % 4 = 2 → 102 % 5 = 2 (stayed)
key "img:3" hash=103 → 103 % 4 = 3 → 103 % 5 = 3 (stayed)
key "user:50" hash=104 → 104 % 4 = 0 → 104 % 5 = 4 (MOVED)
key "user:51" hash=105 → 105 % 4 = 1 → 105 % 5 = 0 (MOVED)
...
The first rows here happen to stay only because their hash lands on the same index under both moduli — a coincidence, not the norm; they aren't representative. Across all keys, the true fraction that moves going N→N+1 is ~N/(N+1) (≈ 4/5 here, approaching 1 as N grows). In a cache, "relocate" means "the key is now looked up on a node that doesn't have it" — a mass miss event. Suddenly ~80% of reads miss and stampede the database all at once, which can take the database down exactly when you added capacity to help it.
- Pro: trivial to implement; perfectly uniform distribution for a fixed N.
- Con: any change to N remaps almost every key, causing a cache-wide miss storm and a thundering herd on the database. Nodes joining/leaving is normal, not rare.
- Verdict: avoid for a distributed cache. It only works if the node set never changes — which is never true at scale.
Consistent hashing with a hash ring + virtual nodes (recommended — the standard fix)
Hash both keys and nodes onto the same circular space (the hash ring), e.g. positions 0 … 2^32-1 wrapped into a circle. A key is owned by the first node encountered walking clockwise from the key's position. Adding or removing a node only changes ownership of the keys between that node and its predecessor — roughly 1/N of keys move, not almost all of them.
Worked example — ring positions, walk clockwise to the owner:
Ring (clockwise): NodeA@10 ── NodeB@40 ── NodeC@75 ── (wrap to 10)
key "user:42" hash=12 → walk cw → first node ≥12 is NodeB@40 → NodeB
key "post:7" hash=55 → walk cw → first node ≥55 is NodeC@75 → NodeC
key "img:3" hash=90 → walk cw → wraps past 75 → NodeA@10 → NodeA
Add NodeD@60:
key "post:7" hash=55 → first node ≥55 is now NodeD@60 → MOVED to D
everything else unchanged — only keys in (40,60] moved from C to D
Only the slice of keys between the new node and its ring predecessor moves; every other key stays exactly where it was. A membership change becomes a small, local reshuffle instead of a cluster-wide remap.
Virtual nodes fix balance and smoothing. With one ring position per physical node, three problems appear: the ring is lumpy (nodes land unevenly, so some own a much bigger arc than others), and when a node dies all its keys dump onto its single clockwise neighbor, overloading exactly one survivor. The fix is virtual nodes: place each physical node at many positions (say 100–200 tokens each). Now load averages out across the ring, and when a node dies its many small arcs redistribute across all the other nodes, not one — a smooth, even shift.
flowchart LR
K["key → hash position"] --> R["Hash ring<br/>walk clockwise"]
R --> V["Owning virtual node<br/>(one of a physical node's ~150 tokens)"]
V --> P["Physical cache node"]
classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
class R good
How the client routes a key. Each client/proxy holds the ring map (the sorted list of virtual-node positions → physical nodes). To route a key: hash it, binary-search the sorted positions for the first one ≥ the key's hash (wrapping around), and send the command to that physical node. It's a local O(log V) lookup with no directory server on the hot path; the ring map only changes on membership events. (Real Redis Cluster implements this same idea with 16384 fixed hash slots and server-side MOVED/ASK redirects — plus gossip — so when a client's slot map is stale the server corrects the routing rather than the client always holding the full ring.)
Hot keys — the limit hashing can't fix. Consistent hashing spreads different keys evenly, but a single hot key (a viral celebrity's profile taking 50K reads/sec) always hashes to one node — you cannot split one key across nodes by hashing, because every request for that key computes the same position. Handle hot keys separately:
-
Replicate the hot key to several nodes and have clients pick one at random (spreads the read load across replicas).
-
Client-side (near) cache: clients keep a tiny local copy of the hottest keys for a few seconds, so most reads never leave the app server. Bounded staleness (short TTL) keeps it acceptable.
-
Detect and special-case: track per-key request rates and promote the top offenders to the above handling automatically.
-
Pro: a membership change moves only ~1/N of keys — no miss storm. Virtual nodes give even load and smooth failover. Routing is a local computation, no per-request directory.
-
Con: more complex than modulo; needs a ring map distributed to clients and kept fresh on membership changes; hot single keys still need separate handling.
-
Verdict: the standard way to shard a distributed cache, and the default at scale. Consistent hashing for balanced, low-churn placement; virtual nodes for even load and smooth failover; explicit hot-key handling on top.
Warning
Do not shard a cache with hash(key) % N. The instant a node joins or leaves — which is routine — nearly the entire keyspace remaps, every read misses, and the resulting stampede can crash the database you were trying to protect. Use consistent hashing with virtual nodes.
2. Replication & failure handling
Nodes die. With ~32 nodes, a machine failing is a weekly event, not a rare one. If a shard has no replica, losing its node means losing that whole slice of the working set at once — a large miss storm on the database. So each shard is a primary plus one or more replicas, and you need a story for how writes propagate and what happens on failover.
Synchronous vs asynchronous replication. When a SET reaches the primary, how does the replica get it?
Synchronous replication: primary waits for replica ack before returning (situational — durability at a latency cost)
The primary applies the write, forwards it to the replica, and only returns OK to the client after the replica acknowledges. The replica is always current, so a promoted replica loses nothing.
Worked example:
SET user:42 → primary applies → forward to replica → replica acks → OK to client
(client's SET latency = primary + network to replica + replica apply)
- Pro: no acknowledged write is lost when a single replica is promoted on failover — the replica has every write the client was told succeeded. (A simultaneous primary+replica loss can still lose it, and an in-memory cache fsyncs nothing, so this isn't durability in the database sense.)
- Con: every write pays the extra round trip to the replica, raising write latency; if the replica is slow or unreachable, writes stall or must degrade. You're buying durability the cache doesn't strictly need.
- Verdict: works, but usually overkill for a cache. Since the durable database is the source of truth, a lost cache write is recoverable by reloading — paying write latency for cache durability is often the wrong trade. Reserve for the rare case where re-deriving a value is very expensive.
Asynchronous replication: primary returns immediately, replicates in the background (recommended for a cache)
The primary applies the write and returns OK right away, then streams the change to its replica(s) in the background. Writes are fast; the replica lags by a few milliseconds.
Worked example:
SET user:42 → primary applies → OK to client (fast)
↘ background: forward to replica (a few ms later)
The honest consistency story: because replication lags, two things can happen — a read served by a replica can be slightly stale (it hasn't received the newest write yet), and if the primary dies before replicating a just-acknowledged write, that write is lost on failover. Both are acceptable for a cache: a stale value self-corrects on TTL or invalidation, and a lost write is simply refilled from the database on the next miss. This is the AP / best-effort posture — availability and speed over strict consistency.
- Pro: writes stay fast (no wait on the replica); the cluster stays available even if a replica is momentarily slow.
- Con: replica reads may be slightly stale; a primary crash can drop not-yet-replicated writes. Only acceptable because the durable store can refill them.
- Verdict: the default for a distributed cache. The cache is best-effort by design, so async replication's small staleness/loss window is exactly the trade you want — the database backstops both.
Failover and promotion on node loss. The membership layer (gossip or a coordinator) detects the dead primary via missed heartbeats and promotes a replica to primary. Crucially, one node noticing a missed heartbeat must not be enough to promote: a primary that is alive but network-partitioned from one observer is still serving writes, so a single miss that triggers promotion yields two primaries accepting writes — split-brain. Promotion must require a quorum to agree the primary is gone: a majority of nodes, a coordinator like ZooKeeper/etcd, or a quorum of sentinels. With that agreement in hand, clients get an updated ring map pointing at the new primary. With async replication, the promoted replica may be missing the last few unreplicated writes — those keys simply miss and reload from the database.
Warning
Never promote a replica on a single node's missed heartbeats. A primary that is alive but partitioned from one observer keeps taking writes; promote off one miss and you get two primaries — split-brain — accepting conflicting writes. Require a quorum (a majority of nodes, or a coordinator like ZooKeeper/etcd, or a quorum of sentinels) to agree the primary is gone before promoting.
flowchart LR
P["Primary (Shard B)<br/>DIES"] -.->|"heartbeat lost"| M["Membership layer<br/>detects failure"]
M -->|"promote"| Rep["Replica → new Primary"]
M -->|"push ring map"| Cl["Clients route to<br/>new primary"]
classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
class P bad
class Rep good
What happens to a shard's keyspace on node loss. If a shard has a replica, failover promotes it and the keys survive (minus any unreplicated tail). If a node is lost with no healthy replica, its share of the ring is redistributed to the neighboring virtual nodes — those nodes now own the failed node's keys, but they don't have the values, so those reads miss and refill from the database. Consistent hashing makes this graceful: only the failed node's ~1/N slice is affected, and virtual nodes scatter that slice across all survivors rather than dumping it on one. The database sees a bounded, localized burst of misses — not a cluster-wide storm.
Caution
Don't promise a cache never loses a write. With async replication (the sensible default), a primary that dies before replicating drops its newest writes, and a lost node's keys miss until refilled. That's fine only because the durable store is the source of truth. If your design relies on the cache never losing data, you've quietly turned it into a database and taken on durability and consistency problems a cache is built to avoid.
3. Eviction & keeping the cache consistent with the source of truth
The cache holds a working set, not everything — ~1 TB of hot keys, not the whole database. Two problems follow: memory is capped, so you must decide which keys to evict when full; and the cached copy can drift from the database, so you must decide how to keep it fresh.
Eviction under a memory cap. Each node has a hard RAM limit. When a SET would exceed it, the node must drop existing entries to make room. The policy decides which:
- TTL expiry — every entry can carry a TTL; expired entries are removed lazily (on access) and/or by a background sweep. TTL is the simplest freshness and memory control: cold or stale data ages out on its own.
- LRU (least-recently-used) — evict the entry not touched for the longest time. The standard default: recency predicts reuse well for most caches. In practice, exact LRU (a strict access-ordered list) is costly to maintain, so implementations approximate it by sampling a few random entries and evicting the least-recently-used among the sample (this is what Redis does with its
allkeys-lrupolicy) — nearly-LRU quality at a fraction of the bookkeeping. - LFU (least-frequently-used) — evict the entry accessed the fewest times. Better than LRU when a stable set of keys is popular over a long window and you don't want a one-off scan to evict them (Redis offers
allkeys-lfu).
Pick LRU as the sensible default, reach for LFU when popularity is stable and long-lived, and layer TTL on top of either for freshness.
Cache patterns — how the cache and database stay in sync. The pattern decides when the cache is written relative to the database.
Write-back (write-behind): write cache first, flush to database later (situational — fast writes, risk on loss)
The application writes to the cache and returns immediately; the cache flushes the change to the database asynchronously, later. Writes are very fast and the database absorbs a smoothed, batched write rate.
Worked example:
write user:42 → cache updated → OK to app (fast)
↘ background: flush to DB seconds later
- Pro: the fastest writes; batches and smooths database write load.
- Con: the cache briefly holds writes the database doesn't yet have — if the cache node dies before flushing, those writes are lost. That makes the cache temporarily the source of truth for un-flushed data, which contradicts "cache is rebuildable."
- Verdict: situational — powerful for write-heavy, loss-tolerant workloads (metrics, counters), but it puts durability in the cache. Avoid it when the cache must stay a pure, rebuildable copy; prefer cache-aside below.
Write-through: write cache and database together, synchronously (alternative — consistent but slower writes)
Every write goes to the cache and the database in the same operation, synchronously. The cache is always populated and always matches the database on the write path.
Worked example:
write user:42 → update DB AND update cache (both) → OK to app
- Pro: cache and database never diverge on writes; reads after a write always hit fresh data (no cold miss for just-written keys).
- Con: every write pays both stores' latency, and you cache all written keys even ones never read again — wasting memory on cold data the working-set model wants to avoid.
- Verdict: a valid alternative when read-after-write freshness matters and written keys are usually read soon. Otherwise it caches cold data and slows writes; cache-aside is the more common default.
Cache-aside (lazy loading) + invalidation on write (recommended — the default)
Reads lazily populate the cache on a miss; writes update the database and invalidate (delete) the cached copy. Only keys that are actually read ever enter the cache — exactly the working-set behavior you want.
Worked example — read then write:
READ user:42 → cache MISS → load from DB → SET cache (TTL) → return
WRITE user:42 → UPDATE DB → DELETE user:42 from cache
NEXT READ → cache MISS → load fresh from DB → SET cache → return fresh
Delete-on-write (not overwrite-on-write) is deliberate: invalidating is safer than trying to write the new value into the cache, because a SET racing with a concurrent reload can leave a stale value behind. Deleting makes stale resurrection far less likely, but it doesn't fully eliminate it: a slow reader that already loaded the OLD value can SET it after the DELETE lands, resurrecting stale data. TTL is the real backstop — it bounds how long any such resurrected entry can live, and it also covers a lost DELETE.
- Pro: only hot (actually-read) keys occupy RAM; the cache never holds a write the database lacks (it's always a rebuildable copy); resilient to lost invalidations via TTL.
- Con: the first read of a cold key always misses (a small latency cost); there's a brief window between the database write and the invalidation where a read can get stale data (bounded by TTL and kept small by invalidating right after the write).
- Verdict: the default pattern for a distributed cache. Lazy-load on miss, invalidate on write, TTL as the backstop — it keeps the cache a pure, rebuildable view of the database.
The invalidation / staleness problem. After a database write, the cached copy is stale until you remove it. Two tools together keep staleness bounded: explicit invalidation (delete the key right after the database write) removes it immediately in the common case, and TTL guarantees an upper bound on staleness even if an invalidation is dropped. Neither alone is enough — invalidation can be lost or race, and TTL alone can serve stale data for the whole TTL window. Use both.
The thundering herd / cache stampede. When a hot key expires (or is invalidated), the next wave of reads all miss simultaneously, and every one of them stampedes the database to reload the same value — a thundering herd. For a key taking 50K reads/sec, that's 50K identical database queries in the instant after expiry.
flowchart LR
E["Hot key expires"] --> M["50K concurrent reads<br/>all MISS at once"]
M --> SF{"single-flight?"}
SF -->|"no"| DB1["50K identical DB queries<br/>database overwhelmed"]
SF -->|"yes"| DB2["1 DB query reloads,<br/>others wait for result"]
classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
classDef good fill:#dcfce7,stroke:#16a34a,color:#14532d;
class DB1 bad
class DB2 good
Fixes:
- Single-flight / request coalescing — let only one request per key reload from the database; the rest wait for that one result and share it. 50K misses collapse into 1 database query.
- Early / probabilistic recompute — refresh a hot key before it expires (e.g. asynchronously when it's close to TTL, or with a small random probability on each read), so it never fully expires under load.
- Slightly randomized (jittered) TTLs — so many keys don't expire at the same instant, spreading the reload load out.
The cache is a derived, rebuildable view. Every one of these decisions — async replication, evicting under a cap, invalidation, refilling misses from the database — is safe for the same reason: the cache is a derived copy of a durable store, never the source of truth. A lost entry, a stale replica read, a dropped write: all recoverable by reloading from the database. Hold onto that framing and the consistency story stays simple.
Warning
Do not rely on TTL alone for freshness — a 60-second TTL means up to 60 seconds of stale reads after a database write. Invalidate explicitly right after the write for immediacy, and keep TTL as the backstop for lost invalidations. And guard hot keys against the stampede on expiry with single-flight.
Tradeoffs & bottlenecks
- Cache as rebuildable copy vs source of truth. The defining choice: treating the cache as a derived, best-effort copy of the database makes availability, async replication, eviction, and lossy failover all acceptable. Give that up and you've built a database. This buys speed and simplicity at the cost of consistency guarantees the cache doesn't need.
- Consistent hashing vs modulo. Consistent hashing (with virtual nodes) moves only ~1/N of keys on a membership change and gives smooth failover, at the cost of a ring map that clients must hold and keep fresh. Modulo is simpler but remaps the whole keyspace on any change — unusable at scale.
- Async vs sync replication. Async keeps writes fast but can serve stale replica reads and drop unreplicated writes on primary loss; sync avoids loss but pays a replica round trip per write. Async is the cache default because the database backstops any loss.
- Eviction policy vs hit rate. LRU (approximated by sampling) is the pragmatic default; LFU protects a stable popular set from one-off scans; TTL bounds staleness and memory. The wrong policy silently lowers the hit rate and pushes load onto the database.
- Cache pattern vs freshness. Cache-aside caches only hot keys and stays a pure copy but has a first-read miss and an invalidation-window race; write-through is fresher but caches cold data and slows writes; write-back is fastest but risks losing un-flushed writes.
- Hot keys. A single hot key can't be split by hashing and remains a per-node bottleneck; replicating it or client-side near-caching spreads the load at the cost of a little more staleness.
Extensions if asked
If the interviewer wants to push beyond the core system, add only the extension that changes the design discussion:
- A durable quorum key-value store (the sibling problem). If the requirement becomes a durable, strongly-consistent store rather than a best-effort cache, that's a Dynamo-style system with quorum reads/writes (R + W > N) and the full CAP tradeoff — a different design where the store is the source of truth. Worth naming as the boundary of this problem, not building here.
- Rich server-side data structures. Redis supports lists, sets, sorted sets, and atomic server-side operations (e.g. leaderboards via sorted sets) — extends the value model beyond opaque bytes.
- Persistence / warm restart. Optional snapshotting or an append-only log so a restarted node reloads its data instead of starting cold — narrows, but doesn't remove, the "cache is rebuildable" framing.
- Multi-region caching. Replicating hot keys across regions for low-latency global reads, with region-local invalidation — a separate replication-topology concern.
- Cross-key transactions / consistency. Atomic updates spanning multiple keys push toward a transactional store; usually out of scope for a cache.
What interviewers look for & common mistakes
What interviewers usually reward:
- Framing the cache as a derived, rebuildable copy of a durable store — never the source of truth — and using that to justify AP / best-effort behavior everywhere.
- Sharding with consistent hashing + virtual nodes, and explaining why
hash(key) % Nis wrong (mass remap → miss storm) and how virtual nodes give even load and smooth failover. - Replicating each shard with async replication, and giving the honest consistency story (slightly stale replica reads, possible loss of unreplicated writes on failover — recoverable from the database).
- Eviction under a memory cap (LRU as default, approximated by sampling; LFU when popularity is stable) plus TTL, and knowing the cache holds a working set, not everything.
- Cache-aside + invalidation + TTL for freshness, and handling the thundering herd on a hot key's expiry with single-flight / request coalescing.
- Naming hot keys as the limit hashing can't fix, and handling them by replication or client-side near-caching.
Before you finish, do a quick mistake check:
- Did you treat the cache as a rebuildable copy of the database, not the source of truth?
- Did you shard with consistent hashing (and virtual nodes) instead of
hash(key) % N? - Did you explain why modulo hashing causes a cluster-wide miss storm on any membership change?
- Did you give each shard a replica and state the async-replication staleness/loss tradeoff honestly?
- Did you say what happens to a lost node's keys (redistributed via the ring; miss and refill)?
- Did you choose an eviction policy (LRU/LFU/TTL) under a memory cap, and size the cache as a working set, not the whole database?
- Did you keep the cache fresh with cache-aside + explicit invalidation + TTL, not TTL alone?
- Did you name the thundering herd on a hot key's expiry and fix it with single-flight / early recompute?
- Did you handle hot single keys, which hashing cannot split, with replication or client-side caching?
Practice this live
Run this exact question with our AI voice interviewer and get feedback.
Start the interview →