← System design

Infrastructure

Design Distributed Cache

A distributed cache keeps the data your services read most often in memory, spread across many machines, so the database behind it sees a fraction of the load. Building one, in the style of Redis Cluster or Memcached fleets, means deciding how keys find their node, what gets thrown out when memory fills, and what a client sees when a node dies. The hardest problems are not inside the cache at all but at its edges: hot keys, stampedes and stale data.
Core: slot-based sharding + async replicas + sampled eviction3M operations a secondReads under 1 ms at p993.6 TB of hot data in RAM

01

Requirements

02

Requirements

Functional
  • Store and fetch values by key with get, set, delete, batched reads and an optional time to live per key.
  • Atomic counters and conditional writes (set only if absent, or only if the version matches).
  • Simple structures beyond strings: hashes, sorted sets and lists, for sessions, leaderboards and queues.
  • Capacity grows by adding nodes, with data moving to them while the cache stays online.
  • When memory fills, evict the least useful keys according to a configurable policy.
  • Operators can see memory, hit ratio, hot keys and replication lag per node.
Non-Functional
  • Reads and writes under 1 ms at p99 from inside the same zone.
  • Survive the loss of any single node, or a whole zone, with failover in under 15 seconds.
  • Linear scaling: twice the nodes give roughly twice the throughput and memory.
  • Hit ratio above 95% for the main workloads, so the database is protected.
  • Losing a few seconds of writes on failover is acceptable; it is a cache, not the system of record.
Find out what it is for

Ask whether it is a pure cache in front of a database, or also holds data that exists nowhere else, such as sessions, rate-limit counters or carts. A pure cache can drop everything and refill. Anything else needs persistence and stricter failover, and changes the answer to half the questions that follow.

02

Sizing the cluster

03

Sizing the cluster

Assume the cache sits in front of the main product database for a large consumer app. Peak load is 3M operations a second, about 85% reads, across 2.4 billion keys with values averaging around 1.2 KB.

QuantityWorkingResult
Peak operationsfrom about 4,000 app servers3M / s
Reads and writes85% and 15%2.55M and 450K / s
Memory per key1.2 KB value + key + about 100 B of overheadabout 1.5 KB
Hot data set2.4B keys x 1.5 KBabout 3.6 TB
Usable RAM per node96 GB machine, keep a third free for fork and fragmentationabout 64 GB
Primary nodes3.6 TB / 64 GB, rounded up60 primaries
With one replica each60 x 2120 nodes across 3 zones
Load per primary3M / 60about 50K ops / s, well below a node's ceiling
Network per primary50K x 1.5 KBabout 75 MB / s
Database load saved2.55M reads at a 96% hit ratioabout 100K / s reach the database
Memory decides the node count

A single in-memory node can usually handle well over 100K simple operations a second. Here, RAM sets the size of the cluster, and throughput has plenty of headroom, which is exactly what you want when one key suddenly gets hot.

Why not bigger nodes

A 500 GB node takes a long time to snapshot, resync or fail over, and losing it moves a large slice of traffic onto the database at once. Many medium nodes recover faster and spread the damage.

03

API design

04

API design

Clients speak a compact binary or text protocol over long-lived TCP connections, usually through a library that knows the cluster layout. The core operations look like this.

GETGET user:4471:profileRead one key
// returns the value or nil; nil is a miss and the caller loads from the database > GET user:4471:profile "{\"name\":\"Asha\",\"plan\":\"pro\"}" // many keys in one round trip, only when they share a slot (note the braces) > MGET {cart:9031}:items {cart:9031}:totals
POSTSET key value EX ttl [NX | XX]Write with expiry
> SET user:4471:profile "{...}" EX 3600 OK // NX: only if absent, the building block for a short lock or one-time token > SET lock:report:2026-10 "worker-17" NX PX 30000
POSTINCRBY / HSET / ZADDAtomic operations on structures
> INCRBY rate:ip:203.0.113.9:1727780400 1 (integer) 17 > ZADD board:weekly 8120 player:55 > HSET session:ab12 user 4471 last_seen 1727780412
DELETEDEL keyInvalidate after the source of truth changes
> DEL user:4471:profile (integer) 1 // for big collections prefer UNLINK, which frees memory in the background
GETCLUSTER SLOTSDiscover the layout
// clients cache this map of slot ranges to nodes and refresh it when told the data moved 0-272 primary 10.0.4.11:6379 replica 10.0.7.23:6379 273-545 primary 10.0.5.30:6379 replica 10.0.8.12:6379

04

Architecture

05

Architecture

Smart clients, sharded primaries and their replicasDiagram
flowchart LR
	APP["App server with cluster-aware client"] --> L1["In-process L1 cache for hot keys"]
	APP --> P1["Primary A: slots 0 to 5460"]
	APP --> P2["Primary B: slots 5461 to 10922"]
	APP --> P3["Primary C: slots 10923 to 16383"]
	P1 --> R1["Replica A, other zone"]
	P2 --> R2["Replica B, other zone"]
	P3 --> R3["Replica C, other zone"]
	P1 <-. "gossip" .-> P2
	P2 <-. "gossip" .-> P3
	APP -->|miss| DB[("Primary database")]
	OP["Operator and autoscaler"] --> P1

The keyspace is divided into a fixed number of hash slots, 16,384 in Redis Cluster. A key belongs to slot CRC16(key) mod 16384, and each primary node owns a set of slot ranges. The slot count never changes; only which node owns each slot does. That indirection is what makes rebalancing manageable: you move slots, not individual key ranges.

Clients are cluster-aware. They download the slot map, compute the slot locally and send each command straight to the owning primary, with no proxy hop. If the map is out of date, the node replies with a redirect naming the new owner, and the client refreshes its map. Each primary streams its writes to one or more replicas in other availability zones. Nodes exchange heartbeats and state over a gossip bus, and when most primaries agree a primary is unreachable, its replica is promoted.

The database stays the system of record. Applications use the cache in a cache-aside pattern: read the cache, on a miss read the database and fill the cache with a TTL, and on writes update the database first and then delete the cached key.

Hash slots

A layer between keys and nodes. Adding a node means moving a few hundred slots to it, while every other key stays put.

Single-threaded core per node

Commands run one at a time on each node, so every command is atomic without locks. I/O threads handle network reads and writes around it.

Asynchronous replicas

Replicas trail their primary by milliseconds. They serve as failover targets and, for reads that tolerate slight staleness, extra read capacity.

Client-side L1

A small in-process cache with very short TTLs in each app server absorbs the hottest few thousand keys before they reach the network.

05

Deep dive: placement, eviction, durability and failover

06

Deep dive: placement, eviction, durability and failover

The four decisions

Where does a key live, what leaves when memory is full, what survives a restart, and who takes over when a node dies? Each has a cheap default and a costly edge case.

Choosing where keys live

Plain hash(key) mod N breaks the moment N changes: adding one node to 60 remaps almost every key, and the hit ratio falls to near zero. Two schemes avoid this. Consistent hashing places nodes and keys on a ring and moves only the keys between a new node and its neighbour; with many virtual nodes per server, load evens out. Fixed slots go one step further and keep an explicit table from slot to node, which an operator or autoscaler can change slot by slot.

Moving a slot while serving traffic works in steps. The target marks the slot as importing, the source as migrating, and keys are copied in small batches. During the move, a request for a key that has already left the source gets an ASK redirect to the target for that single request; once every key is copied, ownership flips and clients receive a permanent MOVED redirect.

Multi-key commands need all their keys in one slot. Hash tags solve that: only the part of the key inside braces is hashed, so {cart:9031}:items and {cart:9031}:totals share a slot and can be used in one transaction.

Eviction and expiry

True LRU needs a linked list through every key, which costs memory and cache misses on each access. Production caches approximate it: each key keeps a small access clock, and when memory is needed the node samples a handful of keys, say ten, and evicts the oldest of them, keeping a small pool of good candidates between rounds. With sensible sample sizes, the result is very close to real LRU at a fraction of the cost.

LFU is often better for caches. A burst of one-off reads, such as a batch job scanning user ids, pushes popular keys out of an LRU but not out of an LFU, which counts how often keys are used with a counter that decays over time. Choose the policy per cluster: allkeys-lfu for a pure cache, volatile-* variants when some keys must never be evicted, and noeviction when data loss is worse than refusing writes.

Expiry runs two ways. Reading an expired key deletes it on the spot (lazy expiry). A background task also samples keys with TTLs several times a second and deletes expired ones, repeating quickly when it finds many, so memory held by keys that are never read again is reclaimed.

What survives a restart

OptionHow it worksUse it when
No persistenceMemory only; a restarted node comes back empty and refills from the databasePure cache with a database that can take the refill
Periodic snapshotsA forked child writes the whole dataset to disk every few minutes; fork uses copy-on-write pagesFast warm restarts; losing minutes of writes is fine
Append-only logEvery write appended to a file, flushed every second; rewritten in the background to stay compactSessions, counters or data that must lose at most about a second
Snapshot plus logLog file starts with a snapshot and carries recent writes after itThe usual production choice when durability matters

Forking a large process is the hidden cost. Copy-on-write means pages that change during the snapshot are duplicated, so a write-heavy node can briefly need far more memory. That is why the sizing above leaves a third of RAM free.

Write path and failoverDiagram
flowchart TD
	W["Client sends SET to slot owner"] --> P["Primary applies in memory"]
	P --> A["Reply OK to client"]
	P --> S["Stream write to replica"]
	S --> L{"Primary reachable?"}
	L -->|yes| N["Normal operation"]
	L -->|no| V["Other primaries vote on failure"]
	V --> PR["Replica with most data promoted"]
	PR --> M["Clients follow redirects to the new primary"]

Replication and failover

Replication is asynchronous: the primary acknowledges a write before the replica has it. That keeps writes fast, and it means a failover can lose the last few milliseconds of writes. Replicas track an offset into the primary's replication stream; after a short disconnect, they resume from their offset using a backlog buffer, and only a long gap forces a full resync with a new snapshot.

Failure detection is a vote. Each node pings others, marks a silent peer as possibly failed, and spreads that opinion by gossip. When a majority of primaries agree, the failed primary's replicas start an election, and the one with the most recent data wins. A primary cut off on the minority side of a partition keeps accepting writes until it notices it has lost contact with the majority; setting a minimum number of connected replicas for writes shortens that split-brain window.

The traffic problems

Hot keys. One celebrity profile or flash-sale item can send 300K reads a second to one slot, more than any one node can serve. Spot them with per-key sampling on each node, then serve them from the client L1 cache with a TTL of a second or two, or copy the key under several suffixes (item:77#0 to item:77#7) on different nodes and pick one at random on each read.

Stampedes. When a popular key expires, thousands of requests miss at once and all go to the database. Let only one caller rebuild the value, using a short SET NX lock or in-process request coalescing, while others briefly serve the old value. Refreshing a little before expiry with a probability that grows as the TTL nears zero spreads rebuilds out further, and adding random jitter to TTLs stops many keys written together from expiring together.

Stale data. Deleting the key after a database write leaves a small race: a reader that loaded the old row just before the write can put it back after the delete. Short TTLs bound the damage. For stricter needs, feed invalidations from the database's change stream, or store a version with each value and refuse to write an older version over a newer one.

06

Anti-patterns

⚠

Anti-patterns

!
Treating the cache as the only copy of important data

Asynchronous replication and eviction both drop data by design. An order or a payment that lived only in the cache is gone after a failover or a memory crunch.

Instead: keep the database as the system of record, and enable persistence plus noeviction for the few cache-only datasets such as sessions.
!
One giant node instead of many medium ones

A 400 GB instance takes minutes to snapshot and much longer to resync a replica. Its failure dumps a large share of traffic on the database at once.

Instead: shard across many nodes of tens of gigabytes each, so recovery is quick and the blast radius small.
!
Scanning the keyspace with KEYS in production

The command walks every key on the node while blocking all other commands, freezing a busy node for seconds.

Instead: use incremental SCAN with a cursor, or keep an explicit index such as a set of the keys you will need to list.
!
Caching without a TTL and relying on manual invalidation

Every missed invalidation leaves a stale value forever, and the cache slowly fills with keys nobody reads.

Instead: give every key a TTL with jitter, and treat explicit deletes as a way to be fresher, not the only way to be correct.

07

Tradeoffs and design choices

07

Tradeoffs and design choices

Chosen
Cluster-aware clients with hash slots

No proxy hop, so latency stays low, and resharding moves slots without remapping everything. Clients must handle redirects and multi-key commands must share a slot.

Fast and scalable
Alternative
A proxy tier in front of plain nodes

Clients stay simple and any language works. Every request pays an extra hop, and the proxies are another fleet to size and keep available.

When clients cannot be upgraded
Chosen
Cache-aside with delete on write

The application controls what is cached, the cache can be emptied at any time, and the database stays authoritative. Small races can leave stale values until the TTL ends.

Simple and safe
Alternative
Write-through or write-behind

The cache is updated on every write, so reads are always warm. Write-behind can lose acknowledged writes, and both put the cache in the write path of every change.

For specific hot datasets
Chosen
Asynchronous replication

Writes are as fast as one node can make them. A failover can lose a few milliseconds of writes, which a cache tolerates.

Right for a cache
Alternative
Synchronous or quorum writes

Fewer lost writes, at the cost of latency on every write and slower progress when a replica is slow. If you need this, you may want a database.

Wrong layer for it
Chosen
Redis-style data structures

Counters, sorted sets and hashes solve many problems in a single round trip, and commands are atomic per node.

More than key-value
Alternative
Memcached-style plain values

Multi-threaded and very efficient for simple string caching with a slab allocator. No persistence, no replication and no structures.

Great for pure blobs

08

Failure modes

08

Failure modes

💀
A primary crashes

Its slots are unavailable until a replica takes over, and writes acknowledged in the last few milliseconds are lost.

Detection and promotion complete within seconds. Clients retry with backoff and follow redirects. Applications treat the cache as lossy, so a lost write simply means an extra database read later.
✂️
A network partition splits the cluster

A primary on the minority side keeps accepting writes for a while, and the majority promotes its replica. When the partition heals, the old primary's recent writes are discarded.

Require a minimum number of reachable replicas for writes, keep node timeouts short, and place nodes so a single zone failure never takes a majority of primaries.
🔥
One key becomes very hot

A viral post drives a single key to hundreds of thousands of reads a second. One node saturates its CPU and every other key on it slows down.

Detect hot keys from sampling, serve them from client L1 caches with short TTLs, or replicate them under several suffixes across nodes.
🐘
A popular key expires and the database is flooded

Thousands of concurrent misses rebuild the same expensive value, and the database slows for every user.

Single-flight rebuilds with a short lock, serve the stale value while one caller refreshes, add jitter to TTLs, and refresh early for the hottest keys.
🧩
Memory fragmentation after weeks of uptime

The node reports far more resident memory than data, eviction starts early and the hit ratio falls.

Enable active defragmentation, watch the fragmentation ratio, and roll restarts through replicas first when it stays high.
🔁
Replicas all need a full resync at once

A short network blip outlasts the replication backlog, and every replica requests a full snapshot from its primary, which forks and saturates the network.

Size the backlog for realistic outages, stagger resyncs, and alert when backlog usage approaches its limit.

09

Interview tips

09

Interview tips

1

Begin with the access pattern. Read-heavy, value size, hot set and tolerance for stale data. Those four answers pick most of the design.

2

Explain why plain modulo hashing fails. Then present hash slots or a ring. Interviewers want to hear how little data moves when you add a node.

3

Talk eviction with specifics. Sampled LRU, LFU with decay, and when noeviction is correct.

4

Be honest about durability. Asynchronous replicas lose writes on failover. Say which data can live with that.

5

Solve hot keys and stampedes unprompted. L1 caches, key replication, request coalescing and TTL jitter. These are the questions that separate levels.

6

Know when not to use a cache. If every read must be fresh or every write durable, the cache is not the answer, and saying so shows judgement.

10

Evolution

11

Evolution

Stage 1: one box
A single cache node

One memory cache next to the app. Huge win over hitting the database, until the node restarts or fills up.

Stage 2: high availability
A primary with replicas and a failover monitor

Replicas in another zone and a watcher that promotes one if the primary dies. Capacity is still one node's RAM.

Stage 3: horizontal scale
Hash slots across many primaries

Data spreads over dozens of nodes with cluster-aware clients, and resharding moves slots online.

Stage 4: client-side caching
In-process L1 with invalidation

The cache tells clients when keys they hold change, so hot keys are served from app memory without going stale for long.

Stage 5: a tiered system
L1, shared cache and database with change-driven invalidation

Invalidations come from the database change stream, hot keys are detected and spread automatically, and the cluster scales itself.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.