System Design Interview

Distributed Cache System Design Interview Guide

Simple to describe, surprisingly hard to scale, the tier that stands between every high-traffic product and its database.

L4, Basic single node L5/L6, Distributed cluster L7/L8, Multi-region, hot keys, SLAs
Cartoon of a stick figure at a desk reading an answer straight off a sticky note on their monitor, while a tall filing cabinet stands behind them dusty and cobwebbed from never being opened
01

What the interviewer is testing

A key-value question arrives in two flavours, and the first thing you should do is ask which one is on the table. "Design a cache" (think Redis, Memcached) means speed is everything and durability is optional: a lost entry is a slower request, not lost data. "Design a persistent key-value store" (think DynamoDB, Cassandra, RocksDB) means the opposite, an acknowledged write that disappears is a bug. They share their distribution primitives, consistent hashing and replication among them, but their tradeoff trees diverge from the first decision onward.

This guide designs the cache, and carries the shared machinery: partitioning and consistent hashing (§5), replication (§9), capacity (§3). The durable side is worked through in the companion guide, designing a persistent key-value store, which refers back here for the fundamentals rather than repeating them.

What makes this question deceptively hard is that the core operation, put(key, value) / get(key), is trivially solved with a single hash table. The interview is really asking: what happens when one machine isn't enough? How do you partition 10 TB of data across dozens of nodes? How do you handle node failure without service disruption? How do you deal with one key receiving 100× the traffic of any other?

Level Core question Differentiator
L4 Can you build a working single-node in-memory store? LRU eviction, TTL, basic API
L5 Can you distribute it across N nodes with consistent hashing? Replication, replica reads, hot key detection & sharding, node failure handling
L6 Can you own the end-to-end reliability story? Full hot key lifecycle (monitoring thresholds, auto-promotion/demotion, write-fan-out cost), rebalancing, monitoring SLAs
L7/L8 Should we build this or use an existing system? Multi-region active-active, cross-DC consistency, cost/latency tradeoffs
02

Requirements clarification

Before drawing anything, nail down whether this is a cache (latency-first, data loss acceptable) or a persistent store (durability-first). These two constraints drive almost every subsequent decision.

Functional requirements

Requirement Scope
get(key) → value or null Core
put(key, value) → OK Core
delete(key) → OK Core
TTL / expiry per key Cache variant
Atomic increment / decrement Rate limiting, counters
Range scans (e.g. keys with prefix) Admin and migration only — a cache is not a scan engine

Non-functional requirements

The numbers below are working assumptions for this design, 10 TB of total data and 1M peak QPS. The estimator in the capacity section (§3) is calibrated to these defaults.

NFR Target Drives
Read latency (p99) < 1 ms In-memory tier, avoid disk on hot path
Write latency (p99) < 5 ms Async replication rather than a synchronous quorum
Availability 99.99% Replication, automatic failover
Consistency Eventual Async replication, staleness bounded by TTL
Durability Not required — if there is a backing store Buffered append log and periodic snapshots, for restart speed rather than safety (§4)
Scale 10 TB data, 1M QPS peak Partitioning strategy
Latency < 1 msWhat it means & what it drives›

Sub-millisecond p99 means all hot-path reads must be served from RAM, no disk I/O allowed on the critical path. A data centre NVMe random read adds ~0.1 ms, about 10% of the budget, survivable on its own but it stacks with a ~0.3 ms network hop and degrades sharply under queueing at p99; a spinning-disk seek adds 4–10 ms and is disqualifying outright. RAM is chosen here to keep the tail predictable, not because one NVMe read exhausts the budget, and NVMe-backed stores like Aerospike do serve sub-millisecond p99 by engineering around exactly that queueing. DynamoDB is the instructive counter-example: it targets single-digit-millisecond p99 and reaches sub-millisecond only through DAX, an in-memory cache bolted in front of it — which is this article's problem, one layer down.

This forces the design toward an in-memory primary with optional disk-backed persistence rather than an LSM-tree on disk. The two forms of that persistence are not interchangeable on restart: an append-only log bounds what a crash loses to its fsync interval, while a periodic snapshot loses everything written since the last one.

SSD does not disappear from the design, it moves off the read path. Snapshots and the append-only log are written to local NVMe asynchronously, so a restarted node reloads from its own disk in seconds instead of stampeding the origin database. That reload is only safe because expiry is persisted as an absolute deadline, not a remaining TTL, so the node drops whatever expired while it was down rather than serving it as a hit; the storage engine (§7) works through what happens to entries that have no TTL to save them. The hybrid, a RAM hot tier over an NVMe warm tier, is a real option once the dataset outgrows affordable RAM; it is weighed as an alternative in the architecture (§4).

ℹ️

Versus a persistent store: the opposite choice, making SSD the primary medium on an LSM-tree, is the design the persistent key-value store guide works through. It buys durability and capacity, and pays for both with a looser latency SLO. That one swap of the primary medium is most of why these are two different systems rather than two configurations of one.

DrivesTwo things. An in-memory storage engine on the cache nodes, since the ~0.3 ms hop to the cluster has to be the whole budget rather than the start of it. And SSD confined to persistence and restart recovery, never to serving a read.
Write latency < 5 msWhere the 5 ms actually goes›

Start from the asymmetry with reads. A read has a shortcut: any replica that happens to hold the key can answer it. A write has none. It has to reach the node that owns the key, so its floor is a full network round trip, and then the value has to be propagated to everywhere the old one is still cached. Both are memory operations; only one of them has more than one node that can answer it. That is where the gap between the budgets comes from.

The client here is the API server, not the browser, and the 5 ms is the cache path. This SLO is measured inside the data centre, from the application server issuing the put to the acknowledgement coming back, and it excludes the internet leg to the end user. The cache acknowledges from the primary: ~0.3 ms round trip, plus an in-memory update and a buffered append to the write log, microseconds, provided that log is flushed on a timer rather than fsynced per write (§7). Under half a millisecond of real work. Replication to the replicas is asynchronous and sits off the acknowledgement path (§9), which is exactly what the eventual-consistency NFR above buys you. So most of the 5 ms is not spent, it is held. It is the room that lets you choose async replication over a synchronous quorum, and it absorbs queueing, GC pauses and retries at p99.

ℹ️

Versus a persistent store: it cannot acknowledge from one machine at all. Durability is required there, so the write has to reach a quorum before it means anything, and each replica has nothing to promise until the record is on stable storage. Same first hop, different ending, and roughly double the budget — the arithmetic is in the persistent key-value store guide.

Everything else follows from what fits in the headroom. Another region's round trip, 60–150 ms, does not, so cross-region replication is asynchronous. An fsync on every write does not, so durability is bought in batches. A synchronous write-through to the backing database does not, so where this SLO binds the cache is invalidated rather than written through (§8).

held, not spent: room to choose async replication over a quorum, plus queueing and GC 0 1 ms 2 ms 3 ms 4 ms 5 ms One cache write, against its 5 ms budget API server ↔ primary 0.3 ms round trip apply on the primary memory write + buffered log append — microseconds cache acknowledges here, ~0.35 ms replication to replicas asynchronous — off the acknowledgement path (§9) Does not fit in the budget: ✗ cross-region RTT, 60–150 ms ✗ synchronous write-through to the DB

The cache answers from the primary and replicates afterwards, so most of the budget is held in reserve rather than spent. That reserve is the design freedom: it is what lets you choose asynchronous replication over a synchronous quorum.

DrivesAsynchronous replication rather than a synchronous quorum, a write log flushed on a timer rather than fsynced per command, and cache-aside or write-behind in place of synchronous write-through.
Eventual consistencyFor cache variant›

For a cache, serving slightly stale data is usually acceptable, the source of truth is the backing database. The cache is an optimisation layer, not the system of record.

This permits asynchronous replication to replicas, which eliminates the write-latency penalty of synchronous quorum acknowledgements. Stale reads are tolerable in both steady state (replication lag between primary and replicas) and during failure modes (the failover window when a replica is promoted).

DrivesAsync replication, configurable TTLs for cache entries, and explicit cache invalidation on write paths.
99.99% availability52 min downtime / year›

99.99% (four nines) permits roughly 52 minutes of downtime per year, or about 60 seconds per week. This rules out manual failover (humans take minutes to respond) and mandates automatic leader election via a coordination service (Zookeeper, etcd).

DrivesReplication factor ≥ 3, automatic sentinel-based failover, health-check endpoints, client-side retry with exponential backoff.
03

Capacity estimation

Caches are almost entirely read-dominated. The write rate matters because it determines replication pressure and invalidation fan-out. The storage number tells us how many nodes we need and whether in-memory storage is economically viable. Cache sizing is derived from the working set, not total data.

Interactive estimator

2.2B
20 : 1
500 B
20.0B
90%
Write QPS
25.0K
writes/sec
Read QPS
500.0K
reads/sec
Peak Read QPS
1.0M
reads/sec (×2)
Total Storage
10.0 TB
all keys × value size
Cache RAM needed
1.0 TB
working set ≈ 9% of keys, + 60 B/entry overhead
Cache nodes (128 GB)
41
×3 replicas, 60% usable RAM
💡

Key insight: At a 90% cache hit rate, the database only sees 10% of reads, a 10× amplification reduction. But notice what the hit-rate slider does to RAM: under a Zipf access distribution, the working set you must cache grows as you chase the tail, and it grows superlinearly. Going 90% → 99% cuts DB load 10× and costs roughly 8× the RAM. The interview answer is not "cache more", it is "here is the hit rate where the next nine stops paying for itself."

The Zipf term is the ln(k)/ln(N) approximation, which is sharpest near the 85–99% knee this widget is built for. Drag the hit rate far below that and it will happily size a single node, because Zipf says a few thousand keys really do carry half the reads. Real caches never run there, they run where the curve bends. Quote the knee, not the rails.

04

High-level architecture

A distributed cache scales horizontally through consistent hashing: each key maps to a position on a hash ring, and the node owning that position holds the key. Adding or removing a node remaps only about 1/N of the keys rather than reshuffling everything (§5). Replication layers on top, typically one primary and two replicas per shard, but the cache replicates asynchronously: the primary acknowledges a write and the replicas catch up behind it. That single choice is what buys the sub-millisecond read and the < 5 ms write in §2.

Clients Load Balancer API Servers Cache Cluster (in-memory KV store) Node 1 Node 2 Node 3 Node N Backing store optional — see below Postgres / DynamoDB / S3 Coordinator (Zookeeper / etcd) Cache lookup Backing-store fallback on miss Coordination (async)

A stateless API tier in front of a distributed in-memory cache cluster, with a coordinator managing membership and leader election. The backing store is drawn dashed on purpose: plenty of production caches do not have one.

Clients are services, web browsers, or mobile apps, anything that issues get/put calls. They connect through a load balancer that distributes requests evenly across API server instances.

API servers are stateless. They cache nothing themselves, which is what lets the load balancer treat them as interchangeable and lets the fleet be scaled on CPU alone. Every read they serve is a lookup in the cache cluster.

Cache cluster (the in-memory key-value store this guide designs) is where every read lands. It holds the working set of the data. Under the Zipf-ish access pattern real traffic follows, the top 10% of keys absorb roughly 90% of reads (§3 models this). Nodes are partitioned using consistent hashing so that adding or removing a node remaps only a small fraction of keys.

Backing store (a relational database, a document store, an object store, whatever already owns the data) is the source of truth, and cache misses fall through to it. It is drawn dashed because it is optional, and saying so out loud is a differentiator. A cache in front of a database has one by definition. But a large share of real Redis and Memcached deployments have nothing behind them at all: session state, rate-limit counters, leaderboards, feature flags, short-lived job state. There the cache is the system of record.

That distinction changes the design rather than decorating it. With a backing store, an eviction or a cold node is a miss: the data is re-read and the only cost is latency, which is why durability is "not required" here and eviction can be aggressive. Without one, the same eviction is data loss, and three things follow.

  • Eviction has to be driven by TTL and capacity planning, not by LRU pressure, because evicting a live session is a user-visible bug.
  • The append-only log and snapshots become the durability story rather than a restart optimisation — see the storage engine (§7).
  • Replication lag becomes a data-loss window on failover, where with a backing store it would merely be a staleness window.

If the interviewer's cache has no backing store, say so early and re-derive these three. It is the fastest way to show you understand what a cache is rather than what it usually sits in front of.

ℹ️

Versus a persistent store: a third case exists, where the durable layer is not behind the cache but is the whole system. That is a different design, not a third configuration of this one: the storage engine lives inside the data nodes instead of in a tier behind them, and there is no cache-aside path to speak of. It is worked through in the persistent key-value store guide.

Coordinator (e.g. Zookeeper, etcd) manages cluster membership, leader election, and failure detection. Nodes heartbeat to the coordinator; when a node goes silent, the coordinator triggers automatic failover to a replica.

ℹ️

What is deliberately not here: a per-server in-process cache sitting in front of the cluster. It is a real optimisation and the single most effective thing you can do about a hot key, but it is not part of the core design, because it buys latency by putting a second, incoherent copy of the data on every API server. That trade is only worth making once you can name the staleness window you are accepting, so it is argued in Hot keys below rather than assumed here.

Architectural rationale

Separate cache cluster from backing storeWhen there is a backing store›

This argument only applies when there is a backing store; if the cache is the system of record, skip to the next item. Where one exists, keeping the two tiers separate lets them be scaled and operated independently. The cache cluster is sized for the working set (RAM), the backing store for the full dataset (disk). Mixing them into one layer means paying disk prices for hot data, or RAM prices for cold data, depending on which way you merge them.

TradeoffTwo-tier architecture adds operational complexity: cache misses incur two hops (cache cluster → backing store). But this is the cost of achieving sub-millisecond p99 for the 90%+ of requests that hit cache.
AlternativesSingle-tier with tiered storage (hot: RAM, warm: NVMe)Read replicas only
External coordinator (Zookeeper / etcd)Why not gossip?›

Some systems (Cassandra, Riak) use gossip protocols for cluster membership, each node shares state with neighbours, eventually converging. This is decentralised and resilient but slower to converge and harder to reason about during partitions.

A dedicated coordinator (Zookeeper, etcd) provides a single consistent view of cluster membership and enforces linearisable leader election. This is the right choice when failover speed and simplicity matter more than eliminating the coordinator as a dependency.

TradeoffThe coordinator itself becomes a reliability dependency, if it fails, nodes can still serve traffic but cannot safely elect a new leader. Run at least 3 coordinator nodes in a Raft quorum for resilience.
AlternativesGossip (Cassandra-style)Redis SentinelRaft embedded in data nodes

Real-world comparison

Decision This design Redis Cluster DynamoDB DAX
Partitioning Consistent hashing (virtual nodes) Hash slots (16384 fixed) None within a cluster — every node holds the whole working set (one primary, up to nine read replicas)
Primary datastore In-memory with optional persistence In-memory + RDB snapshots / AOF operation log In-memory only, write-through to DynamoDB — the cache is never the source of truth
Consistency model Eventual (async replication, acknowledged at the primary) Eventual (async replication to replicas) Eventually consistent only; a strongly consistent read bypasses the cache
Failover External coordinator (etcd) Cluster bus gossip + election Fully managed — a replica is promoted automatically
ℹ️

Neither Redis Cluster's fixed-slot approach nor DAX's managed, write-through model is universally better, the right choice follows from operational ownership (managed vs self-hosted), consistency requirements, and cost model. Note that DAX is the only column here that assumes a store behind it, which is what lets it treat every miss as a latency cost rather than a loss. This interview question is asking you to reason through those tradeoffs, not to recreate Redis.

05

Partitioning & consistent hashing

Consistent hashing solves the key distribution problem by placing both nodes and keys on a circular hash ring. Each key maps to the nearest node clockwise on the ring. When a node is added, it takes over only the keys between itself and its predecessor — on average 1/N of the total keyspace moves, not all keys. Virtual nodes (multiple positions per physical node) even out the distribution and prevent hotspots from uneven key hashing. Without virtual nodes, a node failure can overload its successor with a disproportionate share of traffic.

Once the dataset outgrows a single machine, you need a rule for deciding which node holds which key. The rule must be stable enough that adding or removing a node doesn't remap everything, fast enough to compute on every request, and balanced enough that no single node carries a disproportionate share of the load.

① Naive modulo: hash(key) % N Before (3 nodes) Node A Node B Node C Add Node D → Node A Node B Node C Node D ~75% of keys remapped! hash(key) % 3 ≠ hash(key) % 4 → Cache stampede on node addition (all remapped keys miss cold cache) ② Consistent hashing (hash ring) A B C D E new Only ~1/N keys remapped (keys between E and its predecessor → E)

Naive modulo hashing remaps ~75% of keys when adding a 4th node. Consistent hashing remaps only ~1/N of keys, just those that fall between the new node and its predecessor on the ring.

The core idea of consistent hashing is placing both nodes and keys on a circular hash ring (0 to 2³²). Each key is assigned to the first node clockwise from its position on the ring. When a node is added, only the keys between the new node and its predecessor need to move. When a node is removed, only its keys move to its successor.

Virtual nodes are the fix, and the name oversells them. A virtual node is not a process, a container, or a replica — it is one more position on the ring that points back at a real machine. The ring itself is just a sorted list of (hash, owner) pairs, so giving node-A 150 virtual nodes means inserting 150 rows that all name node-A:

hash("node-A#0") = 0x0e21..  → node-A
hash("node-C#7") = 0x1a94..  → node-C   // a key hashing to 0x13.. lands here
hash("node-A#1") = 0x2f08..  → node-A
hash("node-B#3") = 0x3c7d..  → node-B
...                          // 600 rows for a 4-node cluster

A lookup gains one step of indirection: hash the key, walk clockwise to the next position, then read which physical node that position names. node-A still runs one process holding one slice of memory. Only the routing table got bigger, by a few thousand entries that every client caches.

Scattering each node's positions around the ring buys two separate things, and they are worth keeping apart:

  • Balance stops depending on luck. Positions are hash outputs, which makes them random points on a circle, and a handful of random points spread badly — the four nodes below leave A owning 39% of the keyspace and B owning 11%. That is not an unlucky seed to be rehashed away, it is simply what four random points look like. Averaging is what fixes it: with 150 positions each, a node owns 150 small arcs rather than one large one, and 150 draws sum to something close to the mean.
  • A failure spreads instead of landing on one neighbour. With one position per node, a dead node hands its entire arc to the single node clockwise of it, which then serves roughly twice its normal traffic and becomes the likeliest thing to fail next. With 150 positions, its 150 arcs pass to 150 different successors, so each survivor picks up a percent or two. Adding a node is the same mechanism in reverse: its positions each take a slice from whoever covered that spot, so it draws load from the whole cluster instead of halving one neighbour.
① One ring position per node A B C D hash ring 0 → 2³² share of keyspace 39% A 11% B 25% C 25% D Placement is luck: A owns 3.5× what B owns ② Many virtual positions per node hash ring 0 → 2³² 4 of ~150 positions per node shown share of keyspace 26% A 27% B 24% C 24% D Every node lands everywhere, so the shares even out

The same four nodes, placed once each and placed many times each. Ring position is not something you choose, so with one position apiece the shares come out however the hash happens to land; with many, they converge on a quarter each.

🎯

Common probe: "How many virtual nodes per physical node should you use?" There's no universal answer, more virtual nodes = smoother balance but more memory for the routing table. 100–200 virtual nodes per physical node is a common range (Redis Cluster uses a fixed 16384 hash slots as a deterministic alternative).

ℹ️

How hash slots work. Redis Cluster cuts the keyspace into exactly 16384 slots, a constant baked into the protocol rather than a tuning knob. Every key maps to one with CRC16(key) mod 16384, and each primary owns a set of slot numbers. That slot→node map is gossiped over the cluster bus and cached by every client, so a normal request is still one hop. Resharding hands whole slots from one primary to another; while a slot is in flight its old owner answers MOVED or ASK to redirect the client. Braces pin the hash input, so {user123}:profile and {user123}:cart hash only user123 and land in the same slot, which is what makes multi-key commands possible at all.

The practical difference from virtual nodes is who does the balancing. A ring evens out statistically, and you buy smoothness by adding positions. Slots are explicit inventory: 16384 of them, always, and a hot node is fixed by moving named slots off it.

Rendezvous hashing, an alternativeAdvanced›

Rendezvous (highest random weight) hashing works differently: for each key, compute a score for every node using hash(key, nodeID), and assign the key to the node with the highest score. No ring data structure needed, just iterate over nodes. Adding or removing a node remaps only the keys assigned to that node, like consistent hashing, but the algorithm is simpler to implement correctly.

TradeoffO(N) lookup cost per key (must score all nodes). For small clusters (<30 nodes), this is negligible. For large clusters, consistent hashing with a sorted ring lookup is O(log N) and preferred.

Who runs the ring

Consistent hashing settles which node owns a key. It does not settle which machine does the arithmetic, and that is a separate decision with its own latency bill. There are three places to put it:

Where the hash runs Hops to the data What it costs
In the client One Every client has to hold the ring and learn about every change to it
In a proxy tier Two One place to update, but a hop added to every request and another tier to scale, monitor and keep alive
In the cache nodes One warm, two cold No proxy to run, but the protocol now carries redirects and every client must honour them

The read budget in §2 decides it. Against a sub-millisecond target, a proxy hop spends a large fraction of the budget on routing rather than on the lookup, so the ring belongs in the client — which here means the stateless API servers of §4, since they are the only things that call the cache.

That makes the ring shared state, and §4 already has somewhere to keep it: the coordinator that owns membership and leader election. Nodes register with it, it publishes the resulting ring, and each API server watches for changes and keeps a local copy. The ring is consulted on every single request and changes a few times a year, so the one thing it must never be is a fetch on the request path.

Which leaves the case that actually matters: a client whose copy is out of date. This is not hypothetical — during the migration in §9 the ring changes and clients pick that up at slightly different moments, so for a few seconds they disagree. Stamp the ring with a version and have clients send it on every request. A node receiving a request stamped with a version older than its own refuses it instead of answering, because answering would serve data from the wrong shard, and a confidently wrong value is the one failure a cache must not produce. The client refetches the ring and retries. The cost is one extra round-trip during a window that lasts seconds; the alternative is corruption with nothing to detect it by.

If the coordinator is unreachable, clients keep serving on the last ring they saw. A cache that refuses to answer because it cannot confirm its own topology has converted someone else's outage into its own, and the stale ring is almost always still correct — the version check is there to catch the times it is not.

05b

API design

The interface surface area for a cache is intentionally small. Each operation needs a clear ownership of the key, explicit TTL semantics, and a defined behaviour for non-existent keys.

Core endpoints

GET /v1/keys/{key}

// Request (no body, key in path)
GET /v1/keys/user:1234:session HTTP/1.1
Authorization: Bearer <service-token>

// Response, hit
HTTP/1.1 200 OK
X-Cache: HIT
X-TTL-Remaining: 3542
{
  "key": "user:1234:session",
  "value": "eyJhbGciOiJIUzI1NiJ9...",
  "expires_at": "2026-03-29T12:00:00Z"
}

// Response, miss
HTTP/1.1 404 Not Found
{ "error": "key_not_found" }

// Response, bad or absent credential
HTTP/1.1 401 Unauthorized
{ "error": "invalid_token" }

// Response, authenticated but not entitled to this namespace
HTTP/1.1 403 Forbidden
{ "error": "namespace_forbidden", "namespace": "tenant_7" }

PUT /v1/keys/{key}

// Request
PUT /v1/keys/user:1234:session HTTP/1.1
Authorization: Bearer <service-token>
Content-Type: application/json
{
  "value": "eyJhbGciOiJIUzI1NiJ9...",
  "ttl_seconds": 3600,          // optional; 0 = no expiry
  "if_not_exists": false         // atomic set-if-absent
}

// Response
HTTP/1.1 200 OK
{ "status": "ok", "version": 42 }

// Response, conflict (if_not_exists: true, key already exists)
HTTP/1.1 409 Conflict
{ "error": "key_exists", "current_version": 38 }

DELETE /v1/keys/{key}

HTTP/1.1 204 No Content  // success (idempotent)
HTTP/1.1 404 Not Found   // key didn't exist (acceptable, idempotent)

Optional endpoints by level

Endpoint Purpose Level
POST /v1/keys/{key}/incr Atomic increment (rate limiting, counters) L5
POST /v1/keys/batch/get Multi-key fetch in one round-trip L5
POST /v1/keys/batch/put Bulk write for seeding or migration L6
GET /v1/keys?prefix=user:1234: Range scan / prefix listing L7/L8
GET /v1/admin/stats Hit rate, eviction rate, memory usage per node L7/L8

incr is the one endpoint here that is not safe to retry blindly. GET, PUT and DELETE are idempotent, replaying them lands the same state, but a retried increment after an ambiguous timeout double-counts — and ambiguous timeouts are exactly what a partition produces. If the counter drives billing or rate limiting, take a client-supplied request_id and deduplicate on it for a short window; if you would rather not pay for that, say out loud that the over-count is tolerable and why.

⚠️

Key naming is an API contract. Establish key namespacing conventions upfront (e.g., entity:id:attribute). Unstructured key names make prefix scanning, monitoring, and access control nearly impossible at scale. The API should document and enforce naming conventions from day one.

Conditional writes & Compare-and-Swap (CAS)

The version field in the entry schema (§7) enables optimistic locking. Two clients reading the same key both receive "version": 42. If both try to write, only the first succeeds, the second gets a 409 Conflict with the current version and must re-read and retry.

// Conditional write: only update if version matches
PUT /v1/keys/user:42:balance HTTP/1.1
{
  "value": "150",
  "if_version": 42        // fails with 409 if current version ≠ 42
}

// Response on conflict
HTTP/1.1 409 Conflict
{ "error": "version_mismatch", "current_version": 43, "hint": "re-read and retry" }

CAS is the standard answer to "how do you prevent lost updates when two clients write the same key concurrently?" without requiring distributed transactions. The engine underneath has to supply the atomicity, and it comes in one of two shapes: a server-side script that runs to completion without interleaving, or an optimistic transaction that watches the key, stages the write and aborts if the key changed in between. Pick one shape per operation — a script is already atomic, so wrapping it in an optimistic check buys nothing.

ℹ️

In Redis: the script is a Lua script, which blocks other commands during its run (WATCH cannot be called from inside one and is redundant with it). The optimistic transaction is WATCH + MULTI + EXEC, which aborts if the watched key changed between the WATCH and the EXEC. DynamoDB gets the same guarantee in a single call with ConditionExpression.

Security & multi-tenancy

For a public or multi-tenant cache, three controls are non-negotiable at L6+. First, namespace isolation: prefix all keys with a tenant or service identifier (e.g., tenant_id:entity:id) and enforce this at the API layer, no cross-namespace reads should be possible at the key level. Second, authentication: every service authenticates with its own credential, the default posture is deny, and the cluster is reachable only from a private subnet, never the public internet. Third, value size limits: reject writes above a configured max (e.g., 1 MB) at the API tier to prevent a single key from monopolising a cache node's memory and triggering eviction avalanches.

ℹ️

In Redis: worth stating out loud because the default ran the other way for years. Redis shipped with no authentication at all before 3.2, which produced a wave of high-profile exposure incidents; protected-mode arrived in 3.2 but only refuses non-loopback connections when no password is set, and per-service ACLs only in 6.0. A backstop is not a policy.

Two further controls bind the moment cached values contain personal data, and they are the ones candidates skip because a cache feels ephemeral. Retention: a TTL is a performance setting, not a deletion guarantee. A key holding personal data needs a TTL short enough to satisfy the retention policy and an explicit delete path for erasure requests, one that reaches replicas and any persistence snapshot — the snapshot is where the forgotten copy usually survives. Residency: the asynchronous cross-region replication in §9 moves values across borders by default, so a multi-region deployment under GDPR-style rules needs either per-region namespaces that are excluded from replication, or field-level encryption with keys held regionally. Both are cheap to state in an interview and conspicuous by their absence.

06

Core read/write flow

The most important question in the read path is: what happens on a cache miss? The answer determines latency for cold or low-frequency keys, and also whether the system can defend itself against a cache stampede, where a popular key expires and thousands of requests simultaneously hit the backing store.

GET request Cache hit? HIT Return value ~0.3 ms MISS Acquire lock set-if-absent WON Fetch DB → fill cache → return ~2–10 ms (one request only) LOST (wait) retry on wake Losers never reach the DB — that is the whole point of the lock.

Read path: cache cluster → backing store. Note the ordering: the lock is acquired before the DB is touched. Exactly one request wins the lock — a set-if-absent carrying a unique token and a short TTL — and fetches; the rest back off and re-check the cache, so a stampede on an expired hot key costs one DB query, not thousands.

Cache stampede prevention

When a popular key expires, every concurrent request sees a miss simultaneously and races to query the database, a thundering herd. This is more destructive than it sounds: because the database is provisioned for only 10% of traffic (the 90% hit rate target from the capacity estimate, §3), a stampede that doubles or triples miss volume pushes it past its p99 latency SLO immediately. The tier we deliberately sized down becomes the failure point.

The standard mitigations are: (1) distributed mutex, use an atomic compare-and-set in the cache cluster to let only one request fetch from the database while others wait; (2) probabilistic early recompute (a.k.a. "XFetch"), before a key expires, randomly decide to recompute it based on how close the TTL is, spreading recomputations over time; (3) stale-while-revalidate, return the stale cached value immediately while triggering an asynchronous background refresh.

If you reach for the mutex, give the lock a unique value and release it by checking that value, not with a bare delete. The lock's own TTL — five seconds, say — is a liveness guard: it has to expire, or one crashed holder wedges the key forever. But that means a fetch slower than the TTL loses the lock while still running, a second request acquires it legitimately, and the first one then finishes and deletes a lock it no longer owns, putting two requests in the critical section and handing back the stampede the lock existed to prevent. Releasing with a short server-side script that compares the stored token before deleting closes it, provided the engine runs that script to completion without interleaving. The guarantee is narrower than it looks, and an L6 interviewer will push on exactly that: it makes release safe, but it cannot stop the first holder from still writing after its lease lapsed. For a stampede guard that is fine, the worst case is one redundant DB read. If correctness depended on mutual exclusion you would need fencing tokens checked by the resource itself, which a cache cannot provide.

ℹ️

In Redis: acquisition is SET lock:{key} <token> NX PX 5000, and the compare-then-delete release is a Lua script, which Redis runs atomically: if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end.

Write path

The write path is simpler but the tradeoff is between write latency and consistency. Write-through updates the cache and database synchronously on every write, cache is always fresh but every write pays a database round-trip. Write-behind acknowledges the write after updating the cache, then asynchronously flushes to the database, lower write latency but risk of data loss if the cache fails before the flush. Cache-aside leaves the cache population to read time, the simplest model but means every cold-start incurs a cache miss.

💡

The choice between write-through and write-behind maps directly back to the durability NFR (§2), so it depends on what sits behind the cache. With a backing store, write-behind is acceptable: the worst case is a window of writes that never reached the database. With no backing store, the cache is the record and a buffered write is simply an unacknowledged data-loss window (§4). And if durability is a requirement rather than a nice-to-have, the honest answer is that you are not designing a cache at all, you are designing a persistent key-value store.

07

Data model & storage engine

A key-value store is deceptively simple at the surface, a map from strings to blobs. The interesting design decisions live in what metadata accompanies each entry, how that metadata is stored efficiently, and what storage engine sits underneath.

Access patterns

Operation Frequency Query shape
Point read (get by key) Very high (90%+ of operations) Exact key lookup → O(1) hash table
Point write (put by key) High Exact key update + metadata update
TTL expiry scan Background, continuous Scan entries with expires_at ≤ now
Prefix scan Low (admin / migration) Range scan over sorted key space (requires sorted storage)
LRU eviction Background, on memory pressure Find least-recently-used entry across all keys

Two things jump out from this table. First, point reads dominate overwhelmingly, O(1) hash map access is the right primary structure for an in-memory store, not a B-tree or skip list. Second, TTL expiry and LRU eviction both require efficient ordering by time, which a pure hash map doesn't support. The typical solution is to maintain a hash map for O(1) lookup alongside a doubly-linked list ordered by recency, with the map storing pointers into the list. That works for LRU because every access is a move-to-front, which is O(1). It does not generalise to TTL: expiry deadlines arrive in arbitrary order, so keeping a list sorted by expires_at costs O(n) per insert. TTL wants a min-heap keyed on expiry (O(log n) insert, O(1) peek-min) or a hierarchical timing wheel. A third option is to keep no ordered expiry structure at all and expire lazily on access, with a background cycle sampling random keys to catch what is never read again.

Expiry is not the memory-pressure safeguard, though, and conflating the two is a common slip. Expiry answers "is this entry still valid?"; the eviction policy (§8) answers "what goes when memory runs out?" They decide independently, which is where the trap is: an eviction policy that only considers entries carrying a TTL will find nothing to free in a keyspace that sets none, and writes start failing while memory sits full.

ℹ️

In Redis: expiry is exactly that lazy-plus-sampling scheme — an active cycle samples 20 random keys from the expires dict and repeats while more than 25% are expired. Memory pressure is a separate knob, maxmemory-policy: its volatile-lru, volatile-lfu, volatile-random and volatile-ttl variants evict only keys carrying a TTL, so a key can be dropped well before its deadline once the limit is hit, while the allkeys-* variants take untagged keys too. Set a volatile-* policy over a keyspace with no TTLs and Redis has nothing eligible to evict, so writes fail with OOM on a full node.

Entry (in-memory node)
keystring KEY
valuebytes (opaque)
size_bytesuint32
expires_atint64 (unix ms, 0 = none)
last_accessedint64 (unix ms)
versionuint64 (CAS token)
prev / next*Entry (LRU list pointers)
Persistent log entry (WAL/AOF)
seq_nouint64 PK
openum (SET, DEL, EXPIRE)
keystring
valuebytes (null for DEL)
expires_atint64 (unix ms, absolute)
timestampint64 (unix ms)
crc32uint32 (integrity check)

Storage engine options

In-memory hash map + LRU listCache variant, our choice›

For the cache use case, all data lives in RAM. The data structure is a hash map for O(1) lookup paired with a doubly-linked list ordered by last-access time. On a get, the accessed node moves to the front of the list. On memory pressure, the tail of the list is evicted.

Persistence is optional: an append-only log records all writes, and on restart the log is replayed to reconstruct state. Periodic full-dataset snapshots bound how much log has to be replayed. Be careful with the word WAL here, because the distinction decides what you are allowed to promise. A true write-ahead log (RocksDB, PostgreSQL) fsyncs the record before acknowledging, which is what lets it promise that no acknowledged write is ever lost. A cache's log is normally write-behind: execute first, flush the buffer on an interval, so a crash loses everything written since the last flush. That is the right trade at this latency target — fsyncing per write costs most of the throughput the tier exists for — but it is a promise you have to downgrade deliberately rather than inherit by accident. Redis is the familiar instance: AOF with periodic RDB snapshots, flushing on the default appendfsync everysec so a crash can lose a second of acknowledged writes, with appendfsync always available at a large throughput cost.

Durability is not the interesting risk here, staleness is. A restored snapshot can serve a value the database has since changed, and there are two ways that happens. Writes the node acknowledged but that missed the last snapshot are lost, bounded by the snapshot interval. Far worse, every write and every invalidation that happened while the node was down was never seen by it at all, and no persistence policy fixes that, because a zero-loss log still records only what this node was told. So an empty cache is the safe state: it misses on everything, reads through, and returns current data. A warm cache restored from disk returns hits, and those hits can be wrong. Persistence trades a latency problem for a correctness problem.

What keeps that trade honest is a decision in the entry schema: persist expiry as an absolute deadline rather than a remaining duration, and drop already-expired keys as the file loads. (Redis does exactly this — an absolute Unix-millisecond deadline in RDB, a rewritten PEXPIREAT in AOF, and a master discarding expired keys on load.) Staleness after a restart is then bounded by the TTL you had already accepted, and a restored key is no more stale than one that sat in RAM throughout. The rule that falls out: reloading from disk is safe exactly when every entry carries a TTL. Entries kept indefinitely and invalidated explicitly are the unsafe case, since the node missed the delete and will resurrect the key as a zombie. Guard those by refusing a snapshot older than some staleness threshold and starting cold instead, or by prefixing keys with an epoch that bumps on restart so nothing restored can be hit.

Whether a snapshot on its own is enough is a judgement about what the cache is for, and an interviewer will expect you to argue it both ways. It is viable when every entry carries a TTL and the cache fronts a source of truth. The damage is then bounded on all three axes: expired entries are dropped as the file loads, surviving ones are no more stale than the TTL already permits, and anything missing is simply a miss that re-populates. What you buy is the thing you actually wanted, a warm node in seconds rather than a cold one that stampedes the origin. It is also cheap to take on a read-heavy cache, because the fork's copy-on-write pages stay shared while few keys are being written.

It stops being viable on three counts. Entries with no TTL, invalidated explicitly, have no bound at all, which is the zombie case above. Write-heavy workloads make the fork expensive: copy-on-write duplicates every page touched during the save, so a node rewriting much of its keyspace mid-snapshot approaches double its resident memory and blips exactly the p99 the design exists to protect, which is why the node-sizing model reserves headroom for it. And "reloads in seconds" is a function of dataset size; at hundreds of gigabytes per node that becomes minutes, long enough that starting cold behind a rate-limited origin is the better trade. The usual answer is not to choose: run the append-only log for the bound on loss and periodic snapshots to cap replay time, which is why Redis ships both. Know its actual default though, because it catches people out: snapshots are on, the append-only log is appendonly no until you turn it on, so a stock Redis restarts from exactly the stale file this section warns about.

TradeoffRecovery time is proportional to WAL size. Redis caps this with periodic RDB snapshots and AOF rewriting (compacting the log). Without snapshotting, a restarted node must replay millions of operations.
LSM-tree on diskNot this design›

Worth naming so you can rule it out deliberately rather than by omission. An LSM-tree (RocksDB, Cassandra, LevelDB) turns random writes into sequential disk I/O by buffering them in a MemTable and flushing sorted runs to disk as SSTables, with background compaction merging them. It is the right engine when the dataset exceeds affordable RAM and write throughput is high.

It is the wrong engine here for one reason: a point read may have to check the MemTable plus several SSTable levels, and even with a per-SSTable Bloom filter absorbing most of that, the tail is disk-bound. That breaks the sub-millisecond p99 in §2 the moment the page cache misses. Choose it and the read SLO has to loosen with it.

ℹ️

Versus a persistent store: loosening the read SLO is exactly the trade the persistent key-value store makes, which is why the LSM-tree is its default engine rather than its rejected one. Compaction strategies and the write-amplification maths are worked through there.

TradeoffRejecting disk as the primary medium caps you at what RAM you can afford. The escape hatch is a tiered engine, RAM for the hot set over NVMe for the warm set, which is weighed as an alternative above rather than being a different design.
08

Eviction, TTL & caching strategy

When memory fills up, the cache must decide what to throw away. The right eviction policy depends on the access pattern of the workload. Equally important is how the cache is populated, the caching strategy determines whether writes proactively fill the cache or leave it to reads.

Cache cluster (in-memory KV store) LRU/LFU eviction TTL per key, ~1 TB across 41 nodes ~0.3 ms access miss Backing store (source of truth) No eviction (all data) Disk-backed, replicated ~2–10 ms access

The two tiers of the §4 architecture and what each one throws away. The cache cluster evicts under memory pressure and expires on TTL; the backing store evicts nothing, which is what makes it the fallback.

Eviction policies

Policy How it works Best for Weakness
LRU Evict the key not accessed for the longest time Workloads with temporal locality (session stores, recent feeds) Doesn't account for frequency, a key accessed 1000× a day can be evicted after an idle hour
LFU Evict the key accessed the least total times Workloads with stable hot keys (product catalog, user profiles) Frequency counts decay slowly, a once-viral item stays in cache long after interest drops. Mitigated by decaying frequency counters (Redis LFU uses a logarithmic counter with configurable decay via lfu-decay-time)
ARC Adaptive blend of LRU and LFU, self-tuning Mixed or unpredictable workloads (used in ZFS) More complex to implement; not available in all cache systems
TTL expiry Each key carries a deadline; expired keys are removed lazily or eagerly Data with natural staleness windows (tokens, rate limits, OTPs) Not a memory pressure strategy, expired keys still occupy memory until checked
FIFO Evict the oldest-inserted key Simple queues where insertion order matches usefulness Ignores access pattern completely; rarely optimal
ℹ️

In practice, Redis uses approximate LRU, on eviction, it samples a configurable number of random keys and evicts the least recently used among them. This avoids the overhead of maintaining a full doubly linked list across all keys, no per-entry LRU pointers are needed, at the cost of a small accuracy penalty. Redis stores LRU clock data in 24 bits of each object's metadata rather than in list node pointers.

Caching strategies

Cache-aside (lazy population)Most common, our baseline›

Use this when reads dominate and occasional staleness is acceptable, it is the right default for most read-heavy services. The application manages the cache explicitly: on a miss, read from DB, write to cache, and return; on a write, update DB then invalidate or update the cache key. Because the cache is only populated when data is actually read, rarely-accessed keys never waste cache space.

TradeoffCold start: after a cache restart or key expiry, the first read for each key hits the DB. If many keys expire simultaneously (e.g., after a deploy), this becomes a cache stampede. Use jittered TTLs (add ±10–30% randomness to TTL values) to spread expiry times.
Code patternget → miss → db.fetch → cache.set → return
Write-throughRead-after-write, not strong consistency›

Use this when a read immediately after a write should see the new value on the happy path, and the cost of occasionally not doing so is tolerable. Every write goes to both the primary cache node and the DB synchronously before returning success.

Be precise about what this does not buy you. Writing to two independent systems in one request is a dual write, and dual writes have no atomicity. Three windows follow from that: the cache write succeeds and the DB write fails, leaving the cache serving a value that was never committed; the DB write succeeds and the process dies before the cache write, leaving a stale entry until its TTL; or two concurrent writers interleave their cache and DB writes in opposite orders and diverge permanently. An interviewer who hears "write-through gives strong consistency" will ask about exactly these.

TradeoffWrite latency includes the DB round-trip on every put, and the cache fills with data that may never be read. For write-heavy, read-light workloads, cache-aside is usually more efficient. For money, don't resolve this with a caching strategy at all: read balances from the system of record, or guard the update with a version/CAS token. Where the cache must track the DB, drop write-through for cache-aside with invalidation: the durable ordering is DB first, then invalidate (not update) the cache, with the invalidation published through a transactional outbox so it cannot be lost.
Write-behind (write-back)High write throughput›

Use this for write-coalescing, when many writes target the same key and only the final value matters (view counters, rate-limit windows, leaderboard scores). Writes are acknowledged after updating the cache; the DB write is deferred to an asynchronous flush queue, eliminating the DB round-trip from the critical path.

TradeoffIf the cache fails before the DB flush completes, those writes are lost permanently. Acceptable for counters, view counts, and rate-limit windows; unacceptable for financial transactions, user-generated content, or any data where loss is visible to users.
09

Deep-dive: scalability

Scaling inside one region is mostly ring arithmetic: add shards, add replicas, rebalance. This section is about what that arithmetic stops covering — keeping replicas consistent enough to serve reads, promoting one without a human in the loop, and deciding whether a second region earns what it costs. That last question is the diagram below, and it deliberately runs ahead of the requirements (§2), which set no geographic target, and the estimate (§3), which sizes a single cluster. Treat two active regions as the answer to an NFR you have actually been handed, never as the default shape of a cache.

REGION: US-WEST Clients LB / DNS API Fleet (N servers) Cache Cluster Primary1 Primary2 Replica1 Replica2 Reads → replicas Writes → primaries Backing store Sharded, replicated Postgres / DynamoDB REGION: US-EAST Clients LB / DNS API Fleet (N servers) Cache Cluster Primary1 Primary2 Replica1 Replica2 Reads → replicas Writes → primaries Backing store Sharded, replicated Postgres / DynamoDB async repl

L7/L8Two active regions each with their own cache cluster and persistent store. Async cross-region replication propagates writes with seconds of lag. Local DNS routing sends users to the nearest region.

Replication & failoverConsistency under failure›

Each cache shard (primary node) has one or more replicas. Replicas serve reads, offloading traffic from the primary and providing redundancy. Write path: the client writes to the primary, which acknowledges once the write is applied in its own memory and appended to its buffered write log — not a true WAL, which would fsync first (§7). Replication to replicas is asynchronous, replicas may lag by milliseconds to seconds. Failover is orchestrated by a coordination service (Zookeeper, etcd) as described in §4.

Asynchronous replication has one real cost, and it is the one interviewers probe hardest. A write acknowledged by the primary and not yet shipped to the replicas is lost outright if that primary dies before the next batch goes out. With a backing store that is survivable, the next read misses and re-populates from the database. With no backing store (§4) it is data loss, and the replication lag is your data-loss window, which is why the observability section alerts on lag rather than treating it as a curiosity.

The alternative is to make the write wait for acknowledgements from a quorum of replicas before returning, which converts that loss window into latency. A cache generally should not: it spends the < 5 ms budget from §2 on a wait it does not need, and the whole reason the cache exists is to be faster than the thing behind it.

ℹ️

Versus a persistent store: it generally must pay that wait, because there is nothing behind it to re-read from. Its W/R quorum arithmetic, read repair and anti-entropy are worked through in the persistent key-value store guide.

TradeoffAsynchronous replication buys the latency budget and pays for it in a failover-sized window of lost writes. Quantify the window rather than waving at it: lag × write rate is how many writes you are prepared to lose, and if that number is unacceptable you are being asked for a store, not a cache.
Node rebalancing when adding capacityOperations›

Adding a new node to a consistent hashing ring remaps only ~1/N of keys. But those keys must be migrated from existing nodes to the new one without service interruption. The standard approach is dual-read: during the migration window, read from both the old and new node, accepting a cache miss if the new node doesn't have the key yet. Writes go to the new node immediately. This is also the window in which clients disagree about the ring, which is what the ring version in §5 exists to make safe.

Redis Cluster handles this with hash slot migration, slots move one at a time, with an ASK redirect for any key that's mid-migration. Clients follow the redirect transparently.

TradeoffDuring rebalancing, the new node has a cold cache for migrated keys. If the rebalancing is triggered by a node failure (node went down unexpectedly), the remaining nodes serve increased traffic until the replacement is warm, plan capacity for N-1 nodes handling full load.
Multi-region active-activeL7/L8 depth›

In active-active multi-region setups, both regions accept writes, and changes are replicated asynchronously across the WAN. The unavoidable trade-off: cross-region replication lag (typically 50–200 ms) means a write in US-West isn't immediately visible in US-East.

For a cache, this is usually acceptable, caches are not the system of record, and a slightly stale cache read is fine. The exception is the no-backing-store variant of §4, where the lag is a divergence window in the system of record itself.

ℹ️

Versus a persistent store: conflict resolution becomes load-bearing. If two regions write the same key simultaneously, which value wins? Last-write-wins by timestamp is common but discards valid writes under clock skew; CRDTs (OR-Set, Counter) converge regardless of ordering but restrict the value types the store can support. See the persistent key-value store guide.

TradeoffActive-active maximises availability and reduces write latency by routing to the nearest region. But it requires a conflict resolution strategy and careful consideration of which operations can tolerate eventual consistency vs which require global coordination.
AlternativesActive-passive (simpler, one region handles writes)Geo-partitioning (user data pinned to region)
10

Failure modes & edge cases

Little of what follows is exotic. The failures that actually take a cache down are the ordinary ones: a node dies, a batch of keys expires on the same second, a client retries into a queue that is already full. What separates levels is not knowing the failure exists — it is naming the mitigation and being honest about what the mitigation costs.

Scenario Problem Solution Level
Single cache node crashes No keys are lost. At replication factor 3 every shard the node held still has two live copies, so reads keep being served from cache while the coordinator promotes a replica. What you lose is capacity — 1/41 of cluster RAM — which raises eviction pressure on the survivors until it is replaced. The DB-fallback spike people reach for here is the unreplicated answer; say so explicitly to show you know which one you built Automatic failover to replica (coordinator promotes replica to primary within seconds). Clients retry with exponential backoff. L4
Cache stampede (thundering herd) Popular key expires; all concurrent requests miss simultaneously; DB overwhelmed Distributed mutex on miss path; probabilistic early recompute (XFetch); stale-while-revalidate; jittered TTLs L5
Hot key Single key receives 100×–1000× average traffic; one cache node becomes a bottleneck No server-side fix exists. Every mitigation is client- or topology-side (key splitting, an in-process cache on the API servers, read replicas) and each carries a different cost L5
Network partition (split brain) Nodes cannot reach each other; both sides accept writes and diverge Fencing tokens and coordinator-arbitrated leader election, then accept a stale-read window on the losing side — a cache chooses AP here, because refusing to serve is worse than serving something slightly old. (A store makes the opposite choice: quorum writes at W ≥ N/2 + 1 make the minority side fail rather than diverge.) L6
Dirty cache after DB rollback DB transaction rolled back, but cache already holds the committed (now invalid) value Invalidate cache keys inside the same transaction (or immediately after rollback). Use transactional outbox pattern to enqueue invalidations atomically with DB writes. L6
Cache avalanche (mass expiry) Many keys share the same TTL (e.g., seeded in a batch job); all expire at once; DB overwhelmed for seconds Add ±10–30% random jitter to all TTL values at write time; use sliding TTLs that refresh on read access L6
Large value (cache pollution) A single value (e.g., 10 MB serialised object) fills a large fraction of one node's memory; evicts many small hot keys Enforce a max value size policy (e.g., 1 MB). Large objects should be stored in an object store (S3) with only the reference in cache. Alert on keys exceeding threshold. L7/L8
Cascading failure across services Cache tier fails; every upstream service retries aggressively; DB collapses; entire system goes down Circuit breaker on cache client; request rate limiting at API tier; graceful degradation (serve stale data from a secondary read-only replica or CDN edge); load shedding L7/L8
11

How to answer by level

🎯

The interview differentiator: Every candidate describes a Redis-in-front-of-MySQL architecture. What separates levels is whether you can name the failure of that architecture, and reason from requirements (§2) to the solution without being prompted.

L4, SDE IIEntry level›
What good looks like
  • Clear API: get/put/delete with TTL
  • Correct LRU implementation (hash map + doubly linked list)
  • Single-node in-memory design with an append-only log for durability
  • Identifies that cache misses must fall through to DB
  • Explains cache-aside pattern correctly
What separates from L5
  • Cannot explain what happens when one node isn't enough
  • No mention of cache stampede or how to prevent it
  • Treats the design as complete without discussing failure
  • Cannot name a partitioning strategy beyond "split the data"
L5, Senior SDESenior›
What good looks like
  • Consistent hashing with virtual nodes, explains rebalancing impact
  • Names cache stampede, applies mutex or XFetch proactively
  • Discusses replication factor, async vs sync replication tradeoffs
  • Compares LRU vs LFU for the given workload type
  • Explains write-through vs cache-aside and when each applies
  • Proactively names hot key problem; proposes sharding + in-process caching
  • Explains CAS / optimistic locking for concurrent write safety
What separates from L6
  • Cannot reason about what asynchronous replication costs on failover
  • No plan for rebalancing when adding nodes
  • Hot key: detects and shards, but cannot quantify write fan-out cost or auto-adapt thresholds
  • Single-region only; multi-region not considered
L6, Staff SDEStaff›
What good looks like
  • Owns full hot key lifecycle: detection thresholds, auto-promotion/demotion, write fan-out tradeoff analysis
  • Cascading failure analysis; circuit breaker, graceful degradation
  • Cache avalanche: jittered TTLs, sliding windows
  • Dirty cache after DB rollback, transactional invalidation
  • Capacity planning tied to capacity estimation numbers
  • Monitoring: hit rate, eviction rate, node memory, latency SLOs, error budgets
  • Security: key namespace isolation, per-service credentials, value size enforcement
What separates from L7/L8
  • Multi-region is "an idea" but no conflict resolution strategy
  • Cannot reason about CRDT vs LWW tradeoffs
  • No make-vs-buy analysis (Redis vs DynamoDB vs building from scratch)
L7/L8, Principal / DistinguishedPrincipal+›
What good looks like
  • Leads with: "Should we build this or use Redis / DynamoDB?"
  • Multi-region active-active: names conflict resolution strategy upfront (LWW with fencing, or CRDT-based)
  • Defines SLO targets and reverse-engineers the architecture from them
  • Discusses operational cost: node count, RAM cost per GB, cost of data transfer in multi-region
  • Aware of tail latency (p99.9) and identifies which failure modes cause it
  • Proposes a migration plan from single-node to distributed
L7/L8 is differentiated by
  • Business framing, cost, operational ownership, and team capability are first-class constraints
  • Forward planning: what breaks at 10× current load? 100×?
  • Naming real systems (DynamoDB DAX, ElastiCache, Netflix EVCache) and knowing their actual limitations

Classic probes table

Probe L4 answer L5/L6 answer L7/L8 answer
"What happens when a cache node crashes?" Cache misses spike; DB handles more load Replica is promoted; coordinator triggers automatic failover; client retries with backoff Defines RTO target; questions whether replicas are pre-warmed or cold; discusses cascade risk if DB can't absorb the spike; proposes load-shedding
"How do you handle hot keys?" "Put frequently accessed data on faster nodes" L5: Key sharding across N nodes; detect via request sampling; an in-process cache on the API servers absorbs the detected keys. L6: Owns the full lifecycle, configures detection thresholds, automatic promotion/demotion, and quantifies write fan-out cost of sharding. Proposes adaptive sharding factor tied to per-node QPS ceiling; distinguishes read-heavy keys (sharding fine) from write-heavy keys (sharding inappropriate, use probabilistic counting instead); frames as an SLO problem: "what's the per-node latency budget at peak?"
"How do you ensure consistency between cache and DB?" "Clear the cache when the DB changes" Discusses write-through vs cache-aside; identifies dirty-read window; mentions transactional invalidation for rollbacks Names the fundamental impossibility of perfect consistency in distributed systems under failure; proposes version tokens (optimistic locking); discusses read-your-writes consistency via session affinity
"Why not just make the cache bigger to solve all problems?" "RAM is expensive" Working set is bounded by Zipf distribution, top 10% of keys receive 90% of reads; cache beyond the working set has diminishing returns; hot key problem persists regardless of cache size Derives the economic argument: RAM cost per GB vs. DB query cost per million; discusses the inflection point where cache size increase is no longer cost-effective; proposes tiered storage (hot: RAM, warm: NVMe SSD)
12

Numbers to know

Every architectural decision in this article is downstream of about twenty numbers. Not one of them needs to be precise. What earns credit is knowing which number settles which argument: the reason the store is in memory at all, the reason the cluster is 41 nodes rather than 400, the reason chasing a 99% hit rate is a cost decision rather than an engineering one.

The pattern to practise is a single move: name the number, then name the decision it closes. “A spinning-disk seek is 4–10 ms and the read budget is 1 ms, so nothing on the hot path touches disk” is a complete argument in one breath. Reciting latency tables without attaching them to a choice is not.

Latency: the numbers that set the budget

Number Value What it settles
Read latency SLO < 1 ms p99 (cache)
< 10 ms for the persistent store
The budget every number below is measured against. The companion write SLO of < 5 ms p99 is what buys you asynchronous replication instead of synchronous (§2)
In-process cache hit ~0.01 ms, no network What a near cache in front of the cluster would buy, and why that optimisation keeps coming up. It is not a faster cache so much as a different order of magnitude, because the value never crosses the network (Hot keys)
Cache cluster hit ~0.1–0.5 ms Whether the sub-millisecond p99 is reachable at all. It is, but with only a few hundred microseconds of headroom for everything else, which is what forbids a disk touch on the read path (§2, §6)
Round trip inside one data centre ~0.3 ms That the hop, not the lookup, is most of what a cache read costs. The map probe itself is nanoseconds; you are paying for the network. This is why a single MGET beats N sequential gets, and why batching is an API-design concern rather than an optimisation (§5b)
Replica acknowledgement, if you waited for one ~0.3 ms same-AZ
~0.5–1 ms across AZs
What this design deliberately does not spend. The write acknowledges from the primary and replication runs asynchronously behind it, so this cost stays off the acknowledgement path. Quote it when you are asked what a synchronous replica wait would cost: one parallel hop, but the tail becomes the slower of two samples rather than a typical one (§2, §9)
NVMe random read ~0.1 ms Whether an SSD tier can serve the hot path. It is about 10% of the budget, survivable on its own, but it stacks with the network hop and degrades sharply under queueing at p99 — so it belongs in the warm tier, not the hot one (§2, §7)
Spinning-disk seek 4–10 ms That “just keep it on disk” is not an answer. One seek exceeds the entire read budget, which is the whole reason an in-memory tier is mandatory rather than an optimisation (§2)
Cross-region replication lag 50–200 ms That multi-region is an availability story, not a consistency one. A write in US-West is not reliably readable in US-East inside that window, so either clients pin to a region or the design states plainly that it accepts stale cross-region reads (§9)
Availability target 99.99% ≈ 52 min/year How much failover can cost. About 60 seconds of budget per week means detection and promotion have to be automatic; a human in the loop spends the annual budget in one incident (§2, §10)

Throughput and node count: the numbers that decide how many boxes

These are the ones that answer “how big is this cluster, and what happens when one node dies?” — the question that separates an architecture sized to the traffic from one sized to the diagram.

Number Value What it settles
Peak read QPS 1.0 M/sec
2× the 500 K average
What you actually provision against. Sizing to the average is the most common way to arrive at a cluster that falls over on its first busy evening (§3)
Write QPS 25 K/sec Why replication fan-out is a real cost line rather than a footnote. At three replicas that is 75 K writes/sec landing across the cluster, which is what makes invalidation strategy worth arguing about (§3, §8)
Read : write ratio 20 : 1 The character of the whole system. Read-dominated, so hit rate and eviction policy carry far more weight than write-path cleverness (§3)
Load per API server ~100 K QPS Why the network hop is the thing worth optimising. At that rate the ~0.1–0.5 ms round trip to the cluster is a visible cost line, not a rounding error (§4)
Hot key ceiling (single-threaded engines) one key = one core Why a hot key cannot be solved with more RAM or more nodes. Redis keeps command execution single-threaded, so one key's ceiling is one core however large the cluster grows. What can be done about it, and what each option costs, is worked through in Hot keys below
Cache nodes at 128 GB 41 The cluster size — but quote the inputs, not the output: a 1 TB working set, three replicas, 60% usable RAM per node. The 41 is arithmetic; the three inputs are the answer (§3)
Cost of losing one node ≈ 2.4% of cluster RAM, 0% of the keyspace That replication, not capacity, is what protects the hit rate here. One of three copies goes; reads continue from the survivors and the DB sees no miss storm. You only pay the hit-rate cliff if a whole replica set goes at once, or if you chose to run the tier unreplicated to save a third of the RAM bill (§9, §10)
Replication lag < 100 ms intra-region That this is the data-loss window on failover, not just a staleness window. Lag × write rate is the number of acknowledged writes a primary failure can take with it, and it only rounds to zero when something durable sits behind the cache (§4, §9)

Working set and memory: the numbers that decide what fits where

Number Value What it settles
Seconds per day 86,400 ≈ 105 Every QPS conversion you will do out loud. Divide a daily figure by 100,000 and you land within 15%: 2.2 B writes/day → ~22 K/sec against a true 25 K (§3)
Total data vs. cached data 10 TB all keys
1 TB in RAM
That cache sizing is derived from the working set, not the dataset. Sizing RAM against total data is the single most common capacity error on this question, and it inflates the cluster by an order of magnitude (§3)
Hit rate → working set 90% needs ≈ 9% of keys The most useful relationship in the article. Under a Zipf(s≈1) distribution the fraction you must cache for hit rate h over N keys goes as N(h−1), which is why 90% over 20 B keys is ~1 TB and not ~9 TB (§3)
The price of the next nine 90% → 99% costs ~8× RAM Where “just cache more” stops being an answer. Nine points of hit rate cut database load 10× and multiply RAM roughly 8×, so the credible answer names the hit rate at which the next nine stops paying for itself (§3)
Per-entry overhead ~60 B Why the RAM figure is not simply keys × value size. On 500 B values that is ~12% — small enough to round away, large enough that leaving it out of a stated estimate reads as not having thought about it (§3)
Near-cache budget 256 MB, TTL 30 s What makes a second, incoherent cache tier safe if you add one. Keep it tiny and short-lived and cross-process staleness has a bounded 30-second window rather than an unbounded one (Hot keys)
Bloom filter for 100 M keys 125 MB at ~0.8% false positives
10 bits/key, k=7
That the filter is effectively free. Tightening to ~0.1% costs 175 MB and k=10 — still orders of magnitude smaller than the keys themselves. The filter is per SSTable file, not per level (§7)
Max value size 512 MB (Redis)
1 MB default (Memcached)
The one hard wall between the two engines. Everything else in that comparison is a preference you can argue either way; a 4 MB serialised object is a blocker you cannot (§ Redis vs Memcached)
✓

How to use these in the room. Three habits separate a number that lands from one that doesn't:

  • Round hard, then move. 25,463 writes/sec and “about 25 K a second” buy the identical conclusion. Spending twenty seconds on the digits signals you have mistaken arithmetic for the answer.
  • Say the assumption before the number. “Assuming 2.2 B writes/day and 20:1 reads” makes the figure auditable, and lets the interviewer redirect you early rather than after you have built three layers on top of it.
  • Attach the number to a decision immediately. An unattached figure reads as recall. A figure that closes an argument reads as judgement, and it is the same figure either way.

The failure mode to avoid is quoting a ceiling you cannot defend. If you assert that a 90% hit rate needs 9% of the keys, be ready for “why?” — the honest answer is that it follows from assuming a Zipf distribution with s≈1, that the approximation is only sharp near the 85–99% knee, and that you would measure the real distribution before sizing anything on it.

Going deeper, reference sections

★

Interactive: LRU cache simulator

LRU (Least Recently Used) is the eviction policy asked about most in interviews. The data structure is a hash map for O(1) lookup, wired to a doubly-linked list ordered by recency. On every access, the node moves to the head. On eviction, the tail is removed. The challenge is doing this in O(1) for all three operations, get, put, and evict.

LRU Cache, step-by-step visualization

Capacity: 4 slots  ·  Click an operation or type a custom key below.

Quick ops:
Cache slots (MRU → LRU)
Empty
Operation log
No operations yet.
Internal state: hash map → linked list
Empty
HEAD side = most recent; TAIL side = least recent, evicted first.

Why O(1) requires both structures

Hash map alone
  • O(1) get/put ✓
  • Cannot find LRU key without scanning all entries, O(N) eviction ✗
Linked list alone
  • O(1) evict from tail ✓
  • O(N) lookup by key, must scan the list ✗
💡

The combination: hash map stores key → node pointer (O(1) lookup). Doubly linked list stores nodes ordered by recency (O(1) move-to-front and O(1) evict-from-tail). Every operation, get, put, evict, is O(1). Java's LinkedHashMap uses exactly this structure. CPython's functools.lru_cache uses the same dict plus circular doubly-linked list, recycling the evicted node object on each eviction to avoid allocation churn.

Code: LRU in Go (interview-ready)Expandable›
type Node struct {
    key, val    string
    prev, next  *Node
}

type LRUCache struct {
    cap        int
    m          map[string]*Node
    head, tail *Node  // sentinel nodes (never evicted)
}

func NewLRU(cap int) *LRUCache {
    h, t := &Node{}, &Node{}
    h.next, t.prev = t, h
    return &LRUCache{cap: cap, m: make(map[string]*Node), head: h, tail: t}
}

func (c *LRUCache) Get(key string) (string, bool) {
    n, ok := c.m[key]
    if !ok { return "", false }
    c.moveToFront(n)     // O(1), just pointer rewiring
    return n.val, true
}

func (c *LRUCache) Put(key, val string) {
    if n, ok := c.m[key]; ok {
        n.val = val
        c.moveToFront(n)
        return
    }
    if c.cap > 0 && len(c.m) >= c.cap {
        lru := c.tail.prev       // O(1), tail sentinel's prev is LRU node
        c.remove(lru)
        delete(c.m, lru.key)
    }
    n := &Node{key: key, val: val}
    c.m[key] = n
    c.insertFront(n)
}

func (c *LRUCache) remove(n *Node) {
    n.prev.next, n.next.prev = n.next, n.prev
}
func (c *LRUCache) insertFront(n *Node) {
    n.next, n.prev = c.head.next, c.head
    c.head.next.prev, c.head.next = n, n
}
func (c *LRUCache) moveToFront(n *Node) { c.remove(n); c.insertFront(n) }
★

Bloom filters: eliminating wasteful cache misses

The use that matters here is cache penetration: a lookup for a key that doesn't exist anywhere misses the cache cluster and lands on the backing store, which also returns nothing. Nothing gets cached, because there is nothing to cache, so the next identical lookup repeats the entire trip. A filter in front of the cache short-circuits it on the first hop. This matters most on public APIs, where misbehaving or malicious clients probe arbitrary keys and every probe is a free shot at your database.

A Bloom filter is a probabilistic data structure: it can definitively say a key does not exist (no false negatives), but it may occasionally say a key might exist when it doesn't (false positives). The false positive rate is configurable and depends on the bit array size and number of hash functions.

ℹ️

Versus a persistent store: the same structure has a second life inside its storage engine, where every SSTable file carries a filter so a point read can skip a disk seek. That use is covered in the persistent key-value store guide; the mechanics and the maths below are identical in both places.

Inserting key "user:42" Querying key "user:99" (never inserted) user:42 h1(key)=2 h2(key)=5 h3(key)=9 Bit array, m=12 0 0 1 0 2 1 3 0 4 0 5 1 6 0 7 0 8 0 9 1 10 0 11 0 user:99 h1(key)=1 → 0 ✗ h2(key)=5 → 1 ✓ h3(key)=9 → 1 ✓ Bit 1 = 0 → DEFINITELY NOT in set Skip DB lookup entirely, save a round-trip False positive example If h1=2, h2=5, h3=9 (all happen to be set), filter says "MIGHT exist" → DB lookup happens. Rate is tunable: more bits = fewer false positives.

Each box is one slot: the number inside is its index, the digit underneath is its bit. Left: inserting "user:42" sets bits at positions 2, 5, 9 (one per hash function). Right: querying "user:99", bit 1 is 0, so the key definitely doesn't exist. No DB lookup needed.

False positive rate formula

For a Bloom filter with m bits, n inserted elements, and k hash functions, the false positive probability is approximately:

p ≈ (1 − e−kn/m)k

The optimal number of hash functions for a given m/n ratio is k = (m/n) × ln(2). In practice, using 10 bits per element with 7 hash functions achieves a false positive rate of roughly 1%. For a set of 100 million keys, that's 125 MB, orders of magnitude smaller than storing the keys themselves.

Bits per element (m/n) Optimal k False positive rate Memory (100M keys)
6 4 ~5.6% 75 MB
10 7 ~0.8% 125 MB
14 10 ~0.1% 175 MB
20 14 ~0.007% 250 MB
⚠️

Bloom filters cannot support deletion. Clearing a bit when removing a key would corrupt entries from other keys that share that bit. If you need deletions, use a counting Bloom filter (store counts instead of bits, decrement on delete) or a Cuckoo filter (supports deletion with similar memory efficiency). Redis ships both in core as of Redis 8; before that they came from the separate RedisBloom module.

Where Bloom filters sit in the architectureIntegration pattern›

The filter is checked before any cache or DB lookup: if it returns "definitely not present," the request short-circuits immediately with a 404, skipping the database entirely. Where you put it matters more than the interviewer usually lets on. The tempting answer, an in-process filter on each API server that adds every key it writes, is wrong: a PUT k served by server A sets bits only in A's filter, so a later GET k routed to server B sees an unset bit and returns a hard 404 for a key that exists. That is a false negative, the one thing a Bloom filter is supposed to make impossible, and it is a correctness bug rather than a performance one.

Two safe placements: build the filter from the authoritative keyspace and rebuild-and-broadcast it to every API server periodically, or keep one shared filter in the cache tier (RedisBloom) and pay a network hop to consult it. Either way the filter needs periodic rotation, because a standard Bloom filter cannot unset bits: deletes leave their bits behind and the observed false positive rate drifts upward as the keyspace churns. If you need deletion, say counting Bloom filter or cuckoo filter by name.

In LSM-tree storage engines (RocksDB, LevelDB), each SSTable file carries its own Bloom filter in its metadata block, one per file, not one per level. A point read consults the filter of every candidate file it would otherwise open. Since most files won't contain the key, the filters eliminate the large majority of disk reads for non-existent keys, which is why point read performance doesn't degrade catastrophically as SSTables accumulate. Filter memory therefore scales with dataset size (~10 bits/key by default), not with level count.

Production useGoogle BigTable, Apache Cassandra, RocksDB, InfluxDB all use per-SSTable Bloom filters to avoid expensive disk seeks. Redis has no such per-SSTable layer and its set commands are exact; its Bloom and Cuckoo filters ship in core as of Redis 8, and came from the separate RedisBloom module before that.
★

Redis vs Memcached, when to choose which

Interviewers frequently follow up "design a cache" with "would you use Redis or Memcached?" Both are in-memory key-value stores targeting sub-millisecond latency, but they are built for different workload profiles. The answer should come from requirements, not from brand familiarity.

Dimension Redis Memcached
Data structures Strings, lists, sets, sorted sets, hashes, streams, HyperLogLog, geospatial Strings only (arbitrary byte blobs)
Replication & HA Primary–replica built in; Sentinel for failover, Cluster for sharding (16384 hash slots, MOVED/ASK redirects) None native; the client or a proxy shards, and there is no failover to inherit
Persistence RDB snapshots + AOF, configurable durability None, purely volatile; restart = data loss
Threading Threaded I/O since 6.0, but command execution stays single-threaded, so one hot key is one core Multi-threaded execution, scales roughly linearly with core count
Max value size 512 MB per string 1 MB default item cap (raisable with -I, at the cost of slab efficiency)
Server-side compute Lua for multi-key atomicity, pub/sub, Streams with consumer groups None; every composite operation is a client round trip
🎯

Interview move: Don't just list features, and don't reach for differences Memcached doesn't actually have, it has per-key TTLs (exptime, touch) and atomic incr/decr. Say: "We need built-in replication and failover, a server-side sorted set for the sliding-window rate limiter, and Lua for multi-key atomicity. Those are the three Memcached genuinely lacks, so Redis is the right choice. If we were caching uniformly-sized rendered fragments at maximum throughput with no durability requirement, Memcached's per-core scaling would be worth considering." Requirements → decision.

Rate limiting with Redis atomic increment

One of the most common Redis-specific interview follow-ups is implementing a rate limiter. The simplest approach is a fixed-window counter using INCR + EXPIRE, the right first step to explain before graduating to sliding window or token bucket approaches. Name the race while you are there: issued as two round trips, a client that dies between the INCR and the EXPIRE leaves a counter with no TTL. Because the window is stamped into the key, the next window starts clean, so this is a memory leak, one orphaned key per affected user per window, not a lockout. (With an unstamped key such as ratelimit:{user_id} it would be a permanent lockout.) That is why the script below is a script, and it is a common follow-up in its own right. The non-scripted alternative is SET key 0 EX window NX followed by INCR.

Fixed window rate limiter in RedisCode + tradeoffs›
-- Lua script (atomic execution in Redis)
-- Key pattern: ratelimit:{user_id}:{window_start}
-- window_start = floor(current_unix_sec / window_size)

local key    = KEYS[1]         -- e.g. "rl:user:42:1743200"
local limit  = tonumber(ARGV[1]) -- e.g. 100 (requests per window)
local window = tonumber(ARGV[2]) -- e.g. 60 (seconds)

local count = redis.call('INCR', key)
if count == 1 then
    redis.call('EXPIRE', key, window)  -- set TTL only on first request
end

if count > limit then
    return 0  -- rate limited
end
return 1      -- allowed
Fixed window weaknessA user can send limit requests at 11:59:59 and another limit requests at 12:00:01, effectively 2× the limit in a 2-second window straddling the boundary. Sliding window (using a sorted set with ZADD + ZREMRANGEBYSCORE) fixes this but is more expensive. Token bucket (pre-computed tokens with atomic decrement) balances burst handling and accuracy.
★

Hot keys, and why the core design cannot absorb them

Everything so far distributes keys. A hot key is skew in the other dimension: one key taking orders of magnitude more traffic than the average. A viral post, a config entry every request reads, the top-seller product ID. Consistent hashing has nothing to offer here, because its entire guarantee is that a given key lands in exactly one place.

That is why hot keys appear nowhere in the architecture of §4 or the production topology of §9. There is no box to add. Follow what a request for one key actually touches: the key hashes to one slot, the slot lives on one shard, and on Redis that shard executes commands on a single thread. Adding nodes widens the ring and leaves that path untouched; adding RAM buys working-set headroom that a single key never needed. The ceiling is one core, and it is reached while the rest of the cluster sits idle.

So everything below is a workaround, and each one works by moving traffic off that path rather than widening it. That is also the shape of a good answer: name the ceiling first, then pick the workaround whose bill your workload can pay.

Detecting one

Redis ships a diagnostic for this. redis-cli --hotkeys walks the keyspace with SCAN and calls OBJECT FREQ on each key it finds. That command only exists under an LFU maxmemory-policy (allkeys-lfu or volatile-lfu), so on a cluster left at the LRU default it fails outright, which is better to learn before an incident than during one. MONITOR shows the live command stream instead, but costs a large fraction of throughput to run, making it a minute of triage rather than a monitor.

Neither is a production signal. What is: sample a fraction of requests at the API layer and feed them to a heavy-hitters structure such as a count-min sketch or a top-K counter. The threshold that promotes a key should be set relative to the per-server mean, roughly 1000× it, rather than as an absolute QPS number, because the absolute number moves every time the fleet is resized. Netflix’s EVCache does the absorbing half of this in its client library, as a near cache in front of the cluster. The detecting half can sit in that same client library, in a monitoring sidecar, or in a proxy, depending on where the system already owns the routing decision.

The mitigations, and what each one costs

Mitigation What it buys What it costs Where it stops working
An in-process near cache on the API servers Reads for the hot key never leave the API server, so the shard sees one refresh per server per TTL instead of the full read rate A copy of the value in every server’s heap, and a staleness window as wide as that cache’s TTL When the key is written often enough that a TTL-wide staleness window is not acceptable
Server-assisted client caching (CLIENT TRACKING) The same absorption, except Redis pushes an invalidation when the key changes, narrowing staleness from a whole TTL to the flight time of one push message A RESP3 client, or a RESP2 one with its invalidations redirected to a second pub/sub connection, plus server memory for the tracking table: the default mode remembers which keys each connection read, while BCAST mode drops that bookkeeping and pushes every change under a registered prefix to everyone watching it When writes are frequent, because the invalidation push becomes its own fan-out to every connected client
Key splitting post:12345 becomes post:12345#0 through #(N−1), reads pick a copy at random, and the ceiling rises toward N cores. Toward, not to: each suffix hashes independently, so nothing makes the N copies land on N distinct shards, and the spread is worth verifying rather than assuming Write fan-out goes from 1 to N, and the copies are only as consistent as the slowest write among them On write-heavy keys (counters, rate limiters), where the fan-out is the traffic; use probabilistic counting or CRDTs there instead
Read replicas with READONLY Reads spread across the shard’s replicas without touching the key or the client’s hashing at all Replica RAM, and reads as stale as replication lag (§9) On a hot write key: replicas do not accept writes, so the primary’s single thread is still the ceiling
A multi-threaded engine (KeyDB, Dragonfly) Raises the per-key ceiling from one core to however many cores the engine executes commands on Replacing the storage engine, with its own compatibility and operational story It raises the ceiling, it does not remove it: eight cores is 8×, and a key past 8× is back where it started
🎯

The probe: “How do you handle hot keys?” What separates answers is noticing what is absent from that table, which is a server-side setting. Upstream Redis has no configuration that makes one key faster, so every row is client-side or topology-side. An L5 candidate names key splitting. An L6 candidate names the write fan-out it buys and rules it out for counters before being asked.

★

Observability & production operations

L6+ candidates are expected to own the system end-to-end. That includes knowing which signals to monitor, how to diagnose degradation, and how to change the system safely in production. The six signals to instrument on any cache cluster follow directly from the NFRs in §2.

Key metrics to monitor

Metric Target Alert if… Implication
Cache hit rate > 90% Hit rate drops below 85% Working set is growing faster than cache capacity; or TTLs are too short; or a new access pattern emerged. Five points is worth paging on because it is not five points of load: 90% → 85% takes the share of reads reaching the backing store from 10% to 15%, half as much traffic again
Eviction rate Near zero in steady state Evictions spike unexpectedly Memory pressure, cache is full; either add nodes or investigate memory leak (growing value sizes)
p99 read latency < 1 ms p99 > 3 ms Hot key bottleneck, network congestion, or node under CPU pressure from active expiry scanning. The threshold is 3× the SLO, not 5×: paging at 5 ms would collide with the write SLO and leave no room to act before reads breach
p99 write latency < 5 ms p99 > 10 ms A write-through path is now blocking on the backing database; or fsync is stalling the append path; or the replication backlog has grown enough to push back on the primary. Alert on this separately from reads; the causes barely overlap (§2)
Replication lag (intra-region) < 100 ms Lag > 1 s continuously Primary is overloaded or replica can't keep up; reads from replica may return stale data beyond TTL window
Memory fragmentation ratio 1.0–1.5 Fragmentation > 2.0 Memory allocator fragmentation, keys were frequently deleted/resized; restart replica or enable activedefrag yes, which relocates live allocations. MEMORY PURGE only returns already-free pages to the OS and will not move live objects. A ratio below 1.0 means Redis is swapping, which is more urgent than a 2.0 reading
Connection count Below maxclients Connections spike or hold steady at max Connection pool exhaustion at client side; increase pool size or investigate connection leaks

Safe operations playbook

Rolling restart (zero-downtime upgrade)Operations›

Restart replicas first, one at a time. Wait for each replica to fully sync with the primary before proceeding. Then perform a planned failover, elect a replica as the new primary, then restart the old primary. At no point is the cluster operating without a primary for a given shard.

A restarted replica is not cold: it performs a partial or full resync and comes back holding its shard's entire keyspace, so the signal to gate on is sync completion (master_link_status:up, master_sync_in_progress:0), not hit rate. The real cost is paid on the primary, which forks to produce an RDB snapshot for a full resync and buffers writes for the duration, which is exactly why you stagger restarts instead of rolling the fleet at once.

RiskThe cold-node problem is real, but it belongs to an un-replicated cache tier (a Memcached node, or a fresh shard added during a scale-out), not to a resynced replica. There, the new node genuinely starts empty and sends its full share of traffic to the backing store, which is provisioned for ~10% of reads. Ramp it in gradually rather than adding it at full weight, and pre-warm by replaying recent access logs where possible.
Cache poisoning incident responseIncident playbook›

Cache poisoning occurs when a bug writes incorrect values into the cache, a stale or corrupt DB read gets cached, and subsequent requests serve the bad value for the duration of its TTL.

Response: (1) Identify the affected key pattern using the key naming convention. (2) Flush the affected keys with a targeted DEL command or prefix scan. (3) If the blast radius is large (many keys affected), consider flushing the entire cache namespace and accepting a temporary hit-rate collapse while the cache repopulates. (4) Verify the DB source is healthy before allowing the cache to repopulate.

PreventionChecksum or version-stamp values at write time. On read, validate the checksum. A mismatch triggers a DB re-fetch and re-population. This adds a small CPU cost but catches corruption before it propagates.
Capacity scaling (adding nodes)Scaling ops›

When memory utilisation on cache nodes exceeds 75–80%, it's time to add capacity. The consistent hashing ring means adding a node remaps only ~1/N of keys, but those remapped keys will initially miss on the new node.

Safe scaling procedure: (1) Add the new node to the ring in shadow mode (it receives keys but forwards to the existing node for the first hour). (2) Once the new node's hit rate stabilises, promote it to full active participation. (3) Monitor overall cluster hit rate, it should dip 2–5% during rebalancing and recover within 15–30 minutes.

Scale-down cautionRemoving a node is riskier than adding one. The keys from the removed node all move to their successors, which may experience a temporary memory spike. Ensure successor nodes have < 60% memory utilisation before removing a peer.
💡

The L7/L8 framing: An L7 candidate treats monitoring not as an afterthought but as a design constraint. They ask: "How do we detect that the cache is degrading before users feel it?" This is a capacity planning and SLO question, not an alerting question. The answer involves defining error budgets, and the discipline is to put the SLO on the user-visible signal rather than on the cache: "99.9% of reads complete under 10 ms". A cache miss is not an error, it is a slower success, so hit rate is the driver, not the SLI. That 10 ms is not a round number: at a 90% hit rate the slowest 0.1% of reads are the slowest 1% of the 10% that miss, so the objective lands on the backing store's own p99, not on the cache's. The useful derivation runs the other way: given the backing store's latency budget, what is the minimum hit rate that keeps it inside that budget? For this design that lands around the >90% target in the metrics table above, and the error budget is then spent on the latency objective it protects.

How the pieces connect

Nothing above was chosen on taste. Each decision below is pinned to the requirement that forced it, and the chain is worth assembling in one place:

  • 1 NFR: read latency < 1 ms (§2) → all hot-path reads must avoid disk I/O → in-memory primary store with optional append-only persistence (§7) → an in-memory cache cluster in front of the store (§4)
  • 2 Scale: 1M QPS, 10 TB data (§2, §3) → no single node can hold the working set → consistent hashing with virtual nodes for partitioning (§5) → rebalancing strategy when nodes are added (§9)
  • 3 Cache miss rate drives DB load (§3 insight: 90% hit rate = 10× DB reduction) → miss handling becomes critical → cache stampede prevention via mutex + jittered TTLs (§6, §8) → the backing store is sized for the miss rate, not the request rate (§3)
  • 4 NFR: availability 99.99% (§2) → human failover is too slow → automatic leader election via coordinator (etcd/Zookeeper) (§4) → replication factor ≥ 3 with async replica promotion (§9)
  • 5 Durability NFR differs by variant (§2) → cache: write-behind acceptable → persistent store: write-through or WAL mandatory → caching strategy choice drives write latency vs durability tradeoff (§8)
  • 6 Single-region design (§4) → if the NFRs add a geographic latency or availability target → multi-region active-active with async replication (§9) → conflict resolution strategy required (LWW vs CRDT) (§9)

System Design Mock Interviews

AI-powered system design practice with real-time feedback on your architecture and tradeoff reasoning.

Coming Soon

Practice Coding Interviews Now

Get instant feedback on your approach, communication, and code — powered by AI.

Start a Coding Mock Interview →
Also in this series