Scope the requirements
The trap here is scope. A cache is defined as much by what it doesn't do as what it does — the interviewer is watching whether you volunteer the non-goals (durability, transactions, strong consistency) or sleepwalk into building a database.
Functional — what it must do
SET(key, value, ttl?)— store a value with an optional per-key TTL.GET(key)— return the value or null; must be O(1).DELETE(key)— explicit invalidation for cache-aside callers.- Evict least-recently-used entries when a node hits its memory limit.
- Scale horizontally: add or remove nodes without a full reshuffle.
- Explicitly cut: durability across restarts, transactions, secondary indexes, complex queries — it's a cache, the source of truth lives elsewhere.
Non-functional — the numbers that shape the design
- Sub-millisecond get/set on the node; < 10 ms end-to-end including the network hop.
- 100k+ requests/s peak across the cluster.
- Total capacity around 1 TB of cached data.
- High availability — losing a node degrades hit rate, never correctness.
- Eventual consistency is fine: a stale read within a TTL window is the contract callers signed up for.
Everything here is in memory and latency-critical, so my design decisions are data structures first, distribution second. And I want to be explicit that durability is a non-goal — the moment I promise to never lose a write, I'm designing a database, not a cache.
Back-of-envelope estimates
The arithmetic here is about node counts and per-entry overhead — it tells you the cluster is small, the per-node load is comfortable, and the real risks are hot keys and memory bookkeeping, not raw throughput.
| Quantity | Estimate | How you got there |
|---|---|---|
| Total capacity | ~1 TB | Stated requirement; the cache holds the hot subset, not the world. |
| Nodes | 16 × 64 GB | 1 TB ÷ 16 ≈ 64 GB per node — commodity RAM, no exotic hardware. |
| Load per node | ~6k req/s | 100k RPS ÷ 16 nodes. A single Redis-style node does ~100k ops/s, so there's >10× headroom for skew. |
| Per-entry overhead | ~50–100 B | Hashmap slot + two linked-list pointers + expiry timestamp, on top of the value itself. Millions of tiny keys make this dominate. |
| Latency budget | ~0.5 ms network + ~0.1 ms node | Same-DC round trip is the floor — the network hop costs more than the lookup, which shapes the whole client design. |
Per-node load is boring — one node could nearly serve the whole cluster's QPS. The headroom is deliberate: it's what absorbs a hot key before I need cleverer machinery.
Sketch the API
Three commands, deliberately tiny. The interesting decision isn't the API surface — it's that routing intelligence lives in the client library, so server nodes stay simple and never talk to each other on the request path.
- Smart client over proxy tier: the client library hashes the key onto the ring and connects straight to the owning node — zero extra hops on a path where the network already dominates. A proxy tier (Twemproxy-style) centralises routing logic and simplifies polyglot clients, but adds ~0.5 ms to every request. I'd name both and pick the smart client, flagging the proxy as the answer if we had ten languages to support.
- No multi-key transactions, deliberately — cross-node atomicity would need coordination that destroys the latency contract. Callers who need atomic read-modify-write get it per-key (single node, single-threaded), which covers counters and locks.
Data model
The 'data model' is in-memory data structures, and interviewers score the details: this is where the adjacent coding question (LRU cache) lives inside the design question.
Each node is a single-threaded event loop over these structures — the Redis model — because at sub-millisecond operations, lock contention costs more than it saves and single-threaded means zero races by construction. If a profile showed one core saturating before the NIC does, I'd shard the keyspace across threads within the node (Memcached-style) rather than add locks around shared structures.
High-level design
A smart client hashes keys onto a consistent-hash ring of independent cache nodes. Nodes never coordinate on the request path; replication is asynchronous and exists for availability, not durability.
- The application calls
GET(key)through the client library, which hashes the key onto the consistent-hash ring and picks the owning node directly — one network hop. - The node looks the key up in its hashmap (O(1)), lazily checks
expires_at, moves the entry to the front of the LRU list, and returns the value — ~0.1 ms of work. - On
SET, if the node is at its memory limit it evicts from the tail of the LRU list first, then inserts — still O(1). The write is asynchronously streamed to the shard's replica. - Cluster topology (which node owns which ring range) comes from a config service or gossip; the client watches it and re-routes when nodes join or leave.
- If a node dies, its replica is promoted and the client re-routes. Writes in flight are lost — acceptable by contract, because the source of truth is the database behind us.
Deep dives — where the interview is won
O(1) everything: hashmap + doubly-linked list, and why Redis cheats
The canonical LRU construction: a hashmap gives O(1) lookup, and a doubly-linked list keeps entries in recency order — every access unlinks the entry and moves it to the head; eviction pops the tail. Both structures point at the same entry objects, so get, set and evict are all O(1). Interviewers want this named precisely because it's also a top coding question; hand-waving 'we track recency' reads as not knowing it.
Then say what production systems do: Redis implements approximated LRU — it samples a handful of random keys and evicts the least-recent among the sample, because maintaining a perfect global recency list has memory and cache-locality costs. The approximation loses almost nothing on skewed workloads. Naming this signals you've seen the difference between the textbook and the shipped system.
Consistent hashing with virtual nodes is what makes 'add a node' cheap
Naive hash(key) mod N remaps almost every key when N changes — adding one node invalidates the whole cluster and stampedes the database behind it. Consistent hashing places nodes on a ring and each node owns the arc to its predecessor; adding a node moves only 1/N of the keys. That single property is why the technique exists, and I'd say it in exactly those terms.
Raw rings are lumpy — random placement gives some nodes double share. Virtual nodes fix it: each physical node appears at 100–200 points on the ring, smoothing ownership to within a few percent and letting heterogeneous nodes take proportional shares. When a node dies, its load also scatters across everyone instead of doubling up its neighbour.
Hot keys break the partitioning model — read and write cases need different fixes
Consistent hashing balances the keyspace, not the traffic. One celebrity key can drive more QPS than an entire node handles, and no amount of re-sharding helps — the key lives somewhere. For read-hot keys: replicate the key to several nodes and let clients pick randomly, or cache it client-side for a second or two — a 1 s local TTL turns a million reads into a handful without meaningfully changing staleness on a cache that already tolerates it.
Write-hot keys (a global counter, say) can't be replicated away because every replica would need every write. The fix is to split the key — counter:0 through counter:15, sharded across nodes, incremented randomly and summed on read. Detection matters as much as the fix: per-key hit counters feeding the stats endpoint, so the hot key is found by monitoring rather than by an outage.
TTL expiry and replication: correctness from reads, availability without durability
Expiring keys eagerly would need a timer or a scan — both expensive. The production pattern is lazy expiry plus a sampling sweep: a read that finds an expired entry deletes it and returns a miss (correctness comes from the read path), and a background task periodically samples random keys, evicting the dead ones, so untouched expired keys don't leak memory forever. Redis does exactly both, which is worth citing.
Replication is per-shard, asynchronous, and exists only so a node failure doesn't cold-start a sixteenth of the cache. On failure, promote the replica and accept that the last few milliseconds of writes are gone — the caller's contract already covers a miss. What I would not do is make replication synchronous: that doubles write latency to protect data whose source of truth is the database behind us.
Follow-ups you should expect
A hot key's TTL expires and ten thousand callers miss simultaneously — what happens?
How do you keep the cache consistent with the database behind it?
Why not strong consistency between a node and its replica?
When would you pick LFU over LRU?
A cache node dies with no replica — walk me through the blast radius.
Where candidates lose the room
- Designing a single node and asserting it 'scales horizontally' without ever explaining key partitioning — the distribution story is half the question.
- Saying 'LRU eviction' without the hashmap + doubly-linked-list construction — interviewers treat the O(1) detail as the price of admission.
- Ignoring hot keys because 'consistent hashing balances the load' — it balances keys, not traffic, and the follow-up is waiting.
- Adding write-ahead logs, transactions, or synchronous replication to a component whose requirements explicitly waive durability — over-engineering that reads as not understanding what a cache is for.
- Optimising node-internal microseconds while ignoring that the ~0.5 ms network hop dominates end-to-end latency — connection pooling and client placement matter more than a faster hashmap.