Persistent Key-Value Store System Design Interview Guide
Designing the durable half: what an acknowledged write actually costs, and who wins when replicas disagree.
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, not a miss, and every other decision bends around that one commitment.
This guide designs the durable one. The shared distribution machinery, consistent hashing, replication topology, hot keys, capacity, is developed in the companion guide, designing a distributed cache; rather than repeat it here, this guide refers back to it and spends its space on what is genuinely different: what an acknowledgement costs, how an LSM-tree earns its write throughput, and how replicas that disagree are reconciled.
What makes the question deceptively hard is that the core operation, put(key, value) / get(key), is trivially solved with a single hash table and an fsync. The interview is really asking: what does "durable" mean when the machine holding your write dies before it answers? Which replicas have to agree before you say yes? And when two of them disagree, who wins, and how do you find out they disagreed at all?
| Level | Core question | Differentiator |
|---|---|---|
| L4 | Can you build a single-node store that survives a restart? | Write-ahead log, fsync, crash recovery, basic API |
| L5 | Can you replicate it across N nodes without losing writes? | Replication factor, W/R quorums, hinted handoff, node failure handling |
| L6 | Can you reconcile replicas that have diverged? | Version metadata, read repair, anti-entropy, conflict resolution policy |
| L7/L8 | Can you keep the tail latency honest while compaction runs? | Compaction strategy, amplification budgets, multi-region, operational SLOs |
The sentence that sets you apart early: "Before I design anything, what does acknowledged mean here? Is a write durable when one node has it in memory, when one node has it on disk, or when a quorum has it on disk?" Those three answers produce three different systems, and candidates who pick one without noticing are designing by accident.
Requirements clarification
Functional requirements
| Capability | Why it is in scope |
|---|---|
put(key, value) / get(key) / delete(key) |
The whole API. Everything else is a qualifier on these three |
| Conditional put (compare-and-set on a version) | The only primitive that makes concurrent writers safe without a lock service |
| Per-request consistency level | The same cluster serves a shopping cart and an analytics counter; they should not pay the same price |
| Range scan over a sorted key space | Falls out of an LSM-tree almost for free (§7), and is the reason to choose one over a hash index |
| TTL on an entry | Supported, but as a delete scheduled by the compactor, not as an eviction policy |
Note what is out of scope, and say so explicitly: no secondary indexes, no multi-key transactions, no joins. Each of those changes the storage engine rather than sitting on top of it, and offering them unprompted is how a 45-minute design becomes unfinishable.
Non-functional requirements
Working assumptions for this design: 10 TB of logical data, 250k writes/sec and 500k reads/sec at peak, replication factor N=3.
| NFR | Target | Drives |
|---|---|---|
| Durability | No acknowledged write is lost to a single-node failure | Write-ahead log with a real fsync, replication factor ≥ 3, W ≥ 2 |
| Write latency (p99) | < 10 ms | Group-committed fsync, quorum of 2 rather than 3, LSM-tree over B-tree |
| Read latency (p99) | < 10 ms | Bloom filters per SSTable, block cache, bounded level count |
| Consistency | Tunable per request; W+R > N available | Coordinator-side quorum logic, version metadata on every entry |
| Availability | 99.99% | Hinted handoff, sloppy quorum, no single coordinator |
| Scalability | Linear to hundreds of nodes | Consistent hashing with virtual nodes, no global coordination on the request path |
Rationale behind each NFR
Durability: no acknowledged write is lostThe commitment everything else bends around›
"Durable" is not one property. It is a choice of how far a write has travelled before you answer. Three candidate definitions, in increasing cost: the write is in the memory of the node that owns it; the write is in a log on that node's stable storage; the write is in a log on stable storage on several nodes. Only the third survives losing a machine, and at a few hundred nodes losing a machine is not an edge case. It is a Tuesday.
So this design acknowledges after a quorum of replicas have the record in a write-ahead log that has been fsynced. Be careful with the word WAL, because it is often used loosely: a true write-ahead log is fsynced before the acknowledgement, which is what lets it promise anything at all. A log that is merely appended to a buffer and flushed on a timer, which is what most caches do, has a window of acknowledged writes that a power loss erases. That is a perfectly good engineering choice. It is just not durability, and calling it durability in an interview is the kind of imprecision that gets probed. The rest of this article calls this log the commit log, which is the same mechanism under the name the storage-engine literature usually gives it.
Latency < 10 ms, not < 1 msWhy the budget is ten times a cache's›
The interesting question is not why 10 ms is achievable, it is why you cannot have 1 ms. Work backwards from the acknowledgement. A cache answers from one machine's memory: one ~0.3 ms round trip and a microsecond apply, which is why it can promise sub-millisecond reads and a < 5 ms write. This store cannot answer from one machine, because one machine's memory is not durable and one machine's disk is not available.
So the write pays three things the cache does not. It waits for a quorum: with W=2 the coordinator waits for the slower of two replicas, which is a maximum rather than a typical, and maxima have much worse tails than means. It waits for stable storage on each of those replicas, an fsync that costs tens to hundreds of microseconds when batched and milliseconds when the device is busy. And it may wait behind compaction, which is the background work that keeps reads fast and periodically competes for exactly the same disk bandwidth. Sum the typical case and you are near 2 ms; the budget is 10 ms because p99 describes not the typical write but the one that arrives while a level-merge is running.
Reads get the same 10 ms for a different reason: a point read may have to check a MemTable plus several SSTable levels, and a Bloom filter (see the cache guide for how they work) removes most but not all of that.
Consistency is tunable, not chosen onceW + R > N and what it does not buy›
With N=3, setting W=2 and R=2 guarantees that the read set and the write set overlap in at least one replica, so the freshest acknowledged value is present among the responses. State it that precisely, because the common mistake is to say it guarantees you read the latest value. It does not, on its own: having the value among the responses is useless unless the coordinator can tell which of the three it is, which requires version metadata on every entry and a resolution rule (§8).
And even then it is not linearizability. A write that failed after reaching one replica can be resurrected by a later read repair. A sloppy quorum with hinted handoff can satisfy W=2 without a single home replica having the data. Both are deliberate availability trades, and both mean the guarantee you are offering is strong eventual consistency. Saying that phrase, and knowing why it is weaker than it sounds, is an L6 signal.
Capacity estimation
A cache is sized by its working set: how much RAM buys how high a hit rate. A persistent store has no working set to speak of, it holds everything, so the sizing question is different and mostly about disk. Three numbers matter, and only one of them is the obvious one.
| Quantity | Working number | Where it comes from |
|---|---|---|
| Logical data | 10 TB | 20B keys × 500 B values, the stated assumption |
| Replicated data | 30 TB | × N=3. Replication is the dominant multiplier here, not a rounding error |
| Space amplification | 36–45 TB provisioned | × 1.2–1.5 for obsolete versions and tombstones awaiting compaction (§7) |
| Nodes at 4 TB usable NVMe | ~12 minimum, plan ~16 | Provisioned ÷ per-node capacity, then headroom so a node loss does not cascade |
| Write IOPS reaching disk | ~7.5M/sec of logical writes | 250k writes/sec × N=3 × write amplification ~10 (leveled compaction) |
| RAM per node | ~32–64 GB | MemTables + Bloom filters (~10 bits/key) + block cache, not the dataset |
The line worth saying out loud: "The disk I need is not 10 TB, it is roughly 40 TB, and the write throughput I need is not 250k/sec, it is closer to 7.5M/sec." Replication multiplies both, and compaction multiplies the writes again. Candidates who quote the logical number and stop have skipped the two multipliers that actually decide the hardware bill. The same arithmetic run backwards is how you justify a compaction strategy: halving write amplification halves the disks you provision for throughput.
Bloom filter memory is worth a sentence because it is the one RAM cost that scales with the dataset rather than with traffic: at ~10 bits per key it is the stored record count that matters, not the logical one, so 20B keys at N=3 is ~75 GB of filters across the cluster and ~4.7 GB on each of 16 nodes. It is also the cost you least want to cut, because the filters are what keep a point read from touching every level.
The cache-sizing question, how much RAM buys how high a hit rate under a Zipf distribution, is a different calculation with a different shape; there is an interactive estimator for it in the cache guide.
High-level architecture
The shape of this system is the thing most candidates get wrong, because they reach for the picture they have drawn before: a cache in front, a database behind. There is no such split here. Every node is a full participant, holding a token range of the keyspace and running a storage engine on its own local disk. A client's request is handled by whichever node it reaches, acting as coordinator for that one request, which fans it out to the N replicas that own the key and waits for W or R of them.
That is the structural difference worth stating plainly: the durable layer is not a tier behind the system, it is the system. There is no cache-aside path, no miss, no second hop to a database. Requests that a cache would have absorbed are served by the same nodes that store the data.
Every node is a peer: it coordinates the requests it receives and stores the ranges it owns. The write is acknowledged once W=2 replicas have it in an fsynced commit log, so the third replica catching up later is normal operation rather than a failure. Compare the cache architecture, where a separate cluster sits in front of an optional backing store.
Coordinator is a role, not a machine. Any node can play it, which is what keeps the request path free of a single point of failure and of a global lookup. The coordinator hashes the key, finds the N nodes that own it on the ring (§5), sends the request to all of them, and applies the quorum rule to the responses it gets back.
Replicas each hold a full copy of their token ranges and run the same storage engine: a commit log for durability, a MemTable for the current writes, and immutable SSTables on disk (§7). Nothing about a replica is special, which is what makes losing one survivable and adding one mechanical.
Membership is tracked by gossip rather than an external coordinator. This is the opposite of the choice the cache guide makes, and deliberately so: a cache cluster wants fast, arbitrated failover to a new primary, while this design has no primary to elect. Every replica can serve, so what it needs is an eventually-consistent view of who is alive, which gossip provides without adding Zookeeper as a dependency on the write path.
Architectural rationale
Leaderless replication, not primary/replicaThe defining choice›
A primary/replica design routes every write for a key through one node. That is simple, it gives you a natural ordering, and it is what a cache cluster does. It also means every write for that key is unavailable for as long as failover takes, and failover is the part of a distributed system most likely to be subtly broken.
Leaderless replication removes the election from the write path entirely: all N replicas are equal, a write goes to all of them, and W of them answering is enough. A node being down stops being an outage and becomes arithmetic, you still have N−1 ≥ W. The cost is that you have given up a single ordering point, so concurrent writes to the same key can genuinely conflict, and you now owe the reader a resolution story (§8). That is the trade: availability bought with conflict handling.
Gossip membership instead of ZookeeperWhy the opposite of the cache›
An external coordinator (Zookeeper, etcd) gives a single linearizable view of who is in the cluster and who leads each shard. That is exactly what a primary/replica cache wants, because electing the wrong primary twice is a correctness bug. Here there is no leader to elect, so the strongest thing the coordinator would give you is the thing you do not need.
Gossip converges more slowly and is harder to reason about during a partition, but it removes a hard dependency from the request path and scales to hundreds of nodes without a quorum of coordinators becoming a bottleneck. Being able to say why the two designs land on opposite answers, rather than reciting one as best practice, is the signal here.
Should there be a cache in front of this?The question you will be asked›
Often, yes, and saying so is not a contradiction. A 10 ms p99 is fine for a shopping cart and far too slow for a fan-out read on a social graph, so production systems routinely put a cache in front of a durable store: DynamoDB has DAX, and plenty of teams run their own Redis tier in front of Cassandra.
What matters is being clear that this is composition, not the same design. The cache is a separate system with its own hit rate, its own invalidation problem and its own failure modes, and it is designed in the cache guide. Reaching for it too early in a store interview is a common trap: it hides the question you were actually asked, which is how the durable layer behaves when a node dies.
Partitioning & replica placement
The partitioning mechanism here is the standard one and it is not what distinguishes this design: both
nodes and keys are placed on a circular hash ring, each key belongs to the first node clockwise from
its position, and each physical node takes many virtual positions so the load spreads evenly. Adding a
node moves roughly 1/N of the keyspace rather than reshuffling everything. That machinery, including
why naive hash(key) % N remaps about 75% of keys when you add a fourth node, the role of
virtual nodes, and rendezvous hashing as an alternative, is developed with diagrams in the
cache guide. It is identical here.
What is not identical is what a token range contains. In a cache, a range holds a copy of something that exists elsewhere, so moving a range is a warm-up problem: the new node starts cold and fills as traffic arrives, and the worst case is a period of extra misses. In a persistent store the range is the data. Three consequences follow, and they are what an interviewer is looking for once you have said "consistent hashing".
What changes when the partition owns durable data
| Concern | In a cache | In a persistent store |
|---|---|---|
| Adding a node | New node starts empty and warms up; a period of extra misses | Terabytes must be streamed off disk from existing replicas before the node can serve; hours, not seconds, and it competes with live traffic for I/O |
| Losing a node | Its share of the working set is re-fetched from the backing store | Replication factor silently drops from 3 to 2 for those ranges until a replacement is rebuilt; a second failure in that window is data loss |
| Virtual node count | Higher is smoother; the only cost is routing-table memory | Higher is smoother and makes rebuilds parallel across more source nodes, but each vnode carries its own SSTables and compaction state, so very high counts fragment the engine |
| Where replicas sit | Largely a performance question | A correctness question: three replicas in one rack is a replication factor of one as far as a rack failure is concerned |
Common probe: "You said N=3. Where do those three replicas live?" The answer that scores is rack- and AZ-aware placement: walk the ring clockwise from the key's position but skip candidates that share a failure domain with one already chosen. Otherwise consistent hashing will cheerfully put all three copies on neighbouring nodes in the same rack, and your durability NFR is a fiction the first time a top-of-rack switch fails. It is also why W=2 is normally read as "two replicas in two different AZs", which is where the extra ~0.5 ms in the write budget goes.
Rebuilding a lost replicaThe operation that decides your real durability›
Durability is usually quoted as a property of the replication factor, but what actually determines it is how long you spend below that factor. When a node dies, its ranges are down to two copies, and they stay there until a replacement has streamed the data. If a node holds 4 TB and you can stream at 200 MB/s without starving live traffic, that is roughly six hours at two copies. The probability of losing a second of the remaining two in a six-hour window is small, but it is the number your durability actually rests on, not the "3" in N=3.
This is the strongest argument for many virtual nodes per physical node: if the dead node's ranges are scattered across the cluster rather than held by two neighbours, the rebuild reads from dozens of sources in parallel and the window shrinks from hours toward minutes. It is also the argument for keeping spare capacity, because a cluster running at 90% disk has nowhere to put a rebuild.
Hash partitioning vs. ordered partitioningThe range-scan tax›
Hashing the key is what gives you even load, and it is the right default. It also destroys
key order across the cluster: a scan of user:1000 to user:2000
touches every node, because hashing deliberately scattered those keys. Within a node an
LSM-tree keeps data sorted, so local range scans are cheap; it is the cluster-level scan
that hashing makes expensive.
Ordered partitioning (contiguous key ranges per node, as HBase and Bigtable do) makes cluster-wide scans cheap and reintroduces the hotspot that hashing was there to prevent: monotonically increasing keys, timestamps for example, send every new write to the same node. The usual compromise is a composite key, hash-partitioned on a prefix and sorted within it, which is exactly what DynamoDB's partition-key/sort-key split is.
API design
The surface area is intentionally small — get, put, delete — and almost all of the interesting design is in what the response tells the caller. A durable store has to expose three things a cache never does: which consistency level the operation ran at, what version of the value this is, and how the caller should behave when two versions come back. An API that hides those is an API that cannot honestly claim tunable consistency.
Core endpoints
GET /v1/keys/{key}
// Request — consistency is per-call, not per-cluster
GET /v1/keys/user:1234:profile?consistency=quorum HTTP/1.1
// Response
HTTP/1.1 200 OK
X-Consistency: quorum // what actually ran: one | quorum | all
X-Replicas-Responded: 2/3
X-Read-Repair: triggered // a stale replica was found and is being fixed
{
"key": "user:1234:profile",
"value": "{\"name\":\"Ada\",\"tier\":\"gold\"}",
"version": "a1b2:7,c3d4:2", // opaque causal token — see §8
"updated_at": "2026-09-17T12:00:00Z"
}
// Response, key absent — or deleted and the tombstone has not yet been reaped
HTTP/1.1 404 Not Found
{ "error": "key_not_found" }
Two details are worth defending out loud. consistency is a request parameter
because the same store serves a profile read that can tolerate a stale replica and a balance read that
cannot, and forcing both to the strict setting is how you pay quorum latency for traffic that never
needed it. And version is opaque — the client is expected to echo it back,
never to parse or compare it. The moment clients start interpreting versions, the conflict-resolution
strategy in §8 becomes a public contract you cannot change.
PUT /v1/keys/{key}
// Request
PUT /v1/keys/user:1234:profile HTTP/1.1
Content-Type: application/json
{
"value": "{\"name\":\"Ada\",\"tier\":\"platinum\"}",
"if_version": "a1b2:7,c3d4:2", // the version this write is based on
"consistency": "quorum", // W: one | quorum | all
"ttl_seconds": null // optional expiry; null = keep forever
}
// Response — returned only after W replicas have it in an fsynced log
HTTP/1.1 200 OK
X-Consistency: quorum
X-Replicas-Durable: 2/3
{ "status": "ok", "version": "a1b2:8,c3d4:2" }
// Response — could not reach W replicas within the timeout
HTTP/1.1 503 Service Unavailable
{ "error": "insufficient_replicas", "required": 2, "durable": 1,
"hint": "the write may still be applied — retry is safe only if idempotent" }
The 503 is the interesting response, not the 200. A failed quorum write does not mean the write did not happen — one replica may already have it durably, and anti-entropy will eventually spread it. This is the single most misunderstood property of a leaderless store. The API's job is to be honest about it: report how many replicas were durable, and tell the client that blind retry is only safe for idempotent writes. Saying out loud that "failed" means "undetermined" tells the interviewer you have reasoned about the failure model rather than the method signature.
DELETE /v1/keys/{key}
DELETE /v1/keys/user:1234:profile?consistency=quorum HTTP/1.1
HTTP/1.1 204 No Content // success — writes a tombstone, not a removal
HTTP/1.1 404 Not Found // no live version found (idempotent, safe to repeat)
A delete in a replicated LSM store is a write: it appends a tombstone that shadows every older version of the key until compaction reclaims them (§7). It cannot be an in-place removal, because a replica that was down during the delete would otherwise resurrect the old value the moment it came back and anti-entropy compared it against a key that simply is not there. The tombstone is what lets a returning replica learn that the absence is intentional.
Optional endpoints by level
| Endpoint | Purpose | Level |
|---|---|---|
POST /v1/keys/batch/get |
Multi-key fetch in one round-trip; the coordinator fans out per key | L5 |
POST /v1/keys/batch/put |
Bulk write for seeding or migration; explicitly not a transaction | L6 |
GET /v1/keys?prefix=user:1234: |
Prefix scan — only meaningful with ordered partitioning within a partition key (§5) | L7/L8 |
GET /v1/keys/{key}/versions |
Return all sibling versions unresolved, for a client that merges its own conflicts | L7/L8 |
GET /v1/admin/stats |
Per-node compaction backlog, SSTable count, hint queue depth, repair progress | L7/L8 |
Key naming is a partitioning decision, not a style guide. The key is hashed to pick
the replica set, so the key's shape is the load distribution. A convention like
tenant:entity:id keeps cardinality high and spreads traffic; a key whose first segment
is a status flag or a date puts the whole working set on one replica set. Fix the convention on day
one — it is the one part of the API you cannot change later without rehashing the entire dataset.
Conditional writes & compare-and-swap
The if_version field turns a blind overwrite into optimistic concurrency control. Two
clients read the same key and both receive version a1b2:7. Both compute a new value and
write. The first wins; the second is rejected with the current version and must re-read, re-apply its
change, and retry. This is the standard answer to "how do you prevent lost updates without distributed
transactions", and it is worth being precise about what it costs.
// Response on conflict
HTTP/1.1 409 Conflict
{ "error": "version_mismatch",
"current_version": "a1b2:8,c3d4:2",
"hint": "re-read and retry" }
The uncomfortable part is that on a leaderless store, a compare-and-swap is not free and not quite atomic. The coordinator can only compare against the versions it saw from the R replicas that answered; a partitioned replica may be holding a newer version it has not shown anyone. Two honest ways to close that gap, and interviewers at L6+ will push until you name one:
- Read and write at
allfor the keys that need CAS. Every replica is consulted and every replica is updated, so nothing can be hiding. You have traded availability for correctness on those keys specifically — which is the right trade for a balance and the wrong one for a profile. - Run consensus for that one operation. Cassandra's lightweight transactions do
exactly this: a Paxos round per conditional write, roughly four round-trips instead of one.
DynamoDB exposes the same guarantee as a
ConditionExpression, cheaply, because its partitions are leader-based underneath and the leader can decide alone.
The point to land is that conditional writes are where a leaderless design pays for the leader it does not have. Everything else in this system is cheaper without a leader; this one operation is dramatically more expensive.
Security & multi-tenancy
Three controls are non-negotiable at L6+, and the durability requirement changes what the third one means. First, namespace isolation: prefix every key with a tenant identifier and enforce it at the coordinator, so a cross-tenant read is not expressible rather than merely forbidden. Second, authentication and per-service credentials, with the storage nodes on a private subnet — the inter-node replication port is a far more attractive target than the client API, because it speaks for the cluster rather than for one user. Third, encryption at rest, which a cache can often skip and this system cannot: the data is on disk by design, and it stays there through compaction, snapshots and backups. Encrypt the SSTables and the commit log, and remember that a key rotation has to survive files written months apart.
Two more that come up at L7+: value size limits enforced at the API tier, because a multi-megabyte value is not just memory pressure but a partition that compaction can never rewrite cheaply; and per-tenant rate limiting at the coordinator, since one tenant's hot partition degrades every tenant that shares those replicas.
Core read/write flow
A cache's read path is a story about misses. This system has no misses: every key either exists or does not, and the nodes that own it are the only place it could be. The interesting question moves to the write path, and specifically to the moment the coordinator decides it has heard enough to say yes.
The write path
The acknowledgement is the design. It comes after two replicas have the record in an fsynced commit log, not after all three, and not after one. Everything about the system's latency, durability and availability is a consequence of where that line is drawn.
Read the sequence as four decisions rather than six steps:
- Any node coordinates. The client did not have to find the right node, and there is no routing tier that could go down. The coordinator hashes the key, walks the ring to the N owners, and sends the write to all of them at once, not to W of them. Sending to all and waiting for W is what makes a slow replica cost nothing.
- Each replica makes the write durable before answering. Append to the commit log, fsync, apply to the MemTable. An acknowledgement that skips the fsync is the single most common way a design that claims durability does not have it.
- The coordinator answers at W, not at N. With W=2 the client waits for the second of three responses, so one slow or dead replica is invisible. This is also exactly why the p99 write budget is 10 ms rather than 5: waiting for the second of three is a worse tail than waiting for one.
- The write that did not land is remembered, not lost. The coordinator keeps a hint for the replica it could not reach and replays it when that node comes back. Without hinted handoff, every node restart would leave a permanent hole that only anti-entropy (§8) eventually finds.
Common probe: "What if only one replica is reachable?" Two defensible answers, and the point is to choose out loud. Fail the write, which keeps the W=2 guarantee honest and makes the system CP-flavoured. Or accept it on a sloppy quorum, where any two live nodes, including ones that do not own the key, take the write with hints for the real owners. Dynamo chooses the second and buys availability; the cost is that "W=2 succeeded" no longer means two owners have the data, which quietly weakens the W+R>N overlap argument you made in §2.
The read path
The read is the mirror image, with one addition. The coordinator sends the read to all N replicas, waits for R of them, and then has to do something a cache never does: decide which answer is right. Two replicas returning different values is not a fault. It is the expected result of a write that was acknowledged at W=2 while the third replica was down. The coordinator compares version metadata, returns the winner, and, if it saw a stale replica, writes the winning value back to it in the background. That last step is read repair, and it is the cheapest of the three mechanisms that keep replicas converging, because it costs nothing on the keys nobody reads.
A common follow-up is why you would ever read from more than one replica when they are all supposed to have the data. The answer is that "supposed to" is doing the work: at W=2 of N=3, one replica is allowed to be behind at any moment, so R=1 is a coin flip weighted by how recently that replica was down. R=2 makes the overlap argument work. How the winner is actually chosen, and why a plain timestamp is a trap, is §8.
Storage engine: the LSM-tree
Everything so far has treated a replica as a box that stores things. This is the section where you open the box, and it is the section that separates a distributed-systems answer from a storage answer. The requirement driving all of it was set in §2: a write must be durable before it is acknowledged, on a 10 ms p99 budget, on a device where a random write is expensive and a sequential write is not.
That single constraint rules out the obvious design. Updating a record in place means finding it first — a seek — and then writing a partial page, which on an SSD means the drive reads an erase block, merges your bytes in, and writes it back. The log-structured merge tree's answer is to never update anything: every write is an append, and the cost of keeping the data organised is paid later, in the background, in large sequential batches.
Writes go left to right and cost one sequential append. Reads go top to bottom through the levels and stop at the first version they find, because the newest copy is always the shallowest. Compaction is the background process that pays for both.
What is actually stored
Each record carries more than the value, and each field earns its place:
{
"key": "user:1234:profile",
"value": <opaque bytes>,
"version": "a1b2:8,c3d4:2", // who wrote it, and after what — §8
"timestamp": 1789564800123, // tie-break of last resort
"tombstone": false, // a delete is a write
"ttl": null
}
The tombstone flag is the one people forget, and it has a consequence that reaches the
operations section: tombstones cannot be dropped the moment compaction sees them. If a replica has been
down for longer than the tombstone lived, it will come back still holding the old value and
anti-entropy will happily propagate it back. So a tombstone must survive at least
gc_grace — long enough that a down replica is either repaired or declared dead. Compact too
eagerly and deleted data returns from the dead, which is the failure mode most likely
to end up in a postmortem.
Why the write is fast, precisely
Walk the budget from §2. The commit-log append is sequential, so the device does no seek. The fsync is the only synchronous disk operation on the path, roughly 1 ms on an enterprise NVMe drive with a power-loss-protected write cache. Applying to the MemTable is a skip-list insert, tens of nanoseconds. Nothing else touches disk. One sequential append and one fsync is the entire cost of a durable write — the SSTables, the sorting, the levels are all built later by a background thread that no client is waiting on.
Group commit is the trick worth naming. One fsync per write caps a node at roughly a thousand writes per second, which is nowhere near the target. Instead the engine batches every write that arrives within a small window — a few hundred microseconds — and fsyncs them together. One hundred concurrent writes then cost one fsync, not one hundred. This is why throughput scales with concurrency far past what the per-write latency suggests, and it is a strong detail to volunteer when an interviewer asks how a single node reaches tens of thousands of durable writes per second.
Compaction, and the three amplifications
Compaction merges SSTables, discards shadowed versions, and drops expired tombstones. Compaction is not housekeeping — it is the mechanism that decides what the read path costs, and the strategy you choose is a direct trade between three quantities:
| Amplification | What it measures | Who pays |
|---|---|---|
| Write | Bytes written to disk per byte written by the client | Device lifetime and background IO bandwidth |
| Read | SSTables that must be consulted for one key | Read p99 |
| Space | Disk used per byte of live data | Cost per node, and headroom during a merge |
You cannot minimise all three. The two mainstream strategies sit at opposite ends:
| Size-tiered (STCS) | Levelled (LCS) | |
|---|---|---|
| How it merges | Waits for several SSTables of similar size, merges them into one bigger one | Keeps each level disjoint and ~10× the one above; merges a file down one level at a time |
| Write amplification | Low — roughly 4–8× | High — roughly 10–30× |
| Read amplification | High — a key may live in many overlapping tables | Low — at most one table per level below L0 |
| Space amplification | Poor — up to 2× transiently, since merging the largest tier needs room for a second copy | Good — around 1.1× |
| Fits | Write-heavy, append-mostly: time series, event logs, ingestion | Read-heavy with updates: the workload in §2, at a 2:1 read/write mix |
For this design, levelled compaction is the right default, and the reasoning is the traffic mix rather than a preference: reads outnumber writes two to one, and a read that misses the cache has to touch every level it cannot rule out, so paying more on the write path to keep read amplification near one is trading the cheaper side for the more expensive one. The honest cost to state alongside it is that levelled compaction writes roughly 10–30 bytes for every byte the client sends, which is a real number on SSD endurance and a real amount of background IO competing with the foreground reads you just optimised.
"What happens when compaction falls behind?" This is the L6+ follow-up, and it has a specific, unpleasant answer. SSTable count grows, so read amplification grows, so read latency grows — while the node is already IO-saturated, which makes compaction fall further behind. It is a positive feedback loop, and it does not resolve on its own. The fixes are all capacity decisions made in advance: keep compaction throughput budgeted rather than best-effort, alarm on SSTable count and pending-compaction bytes rather than on latency alone (latency tells you too late), and have a documented procedure to shed write traffic from the node. Naming the feedback loop is worth more than naming the fix.
LSM-tree vs. B-treeStorage engine›
A B-tree updates pages in place and keeps everything sorted at all times. That gives it a read of one to four page fetches, predictable and low, and it makes range scans trivial. It pays for this on the write path: an update seeks to the page, rewrites it, and — because a torn page is unrecoverable — most engines write the page to a write-ahead log first, so the data is written twice. Its write amplification is lower than levelled LSM in bytes but its writes are random, which is the expensive access pattern on the device.
The LSM-tree inverts every one of those. Writes are sequential and cheap, reads pay for it by checking multiple files, and range scans need a merge across levels. Bloom filters recover most of the point-read gap; they do nothing for scans.
What the MemTable size actually controlsTuning›
It looks like a memory knob and is really a compaction knob. A larger MemTable means fewer, bigger flushes, so fewer SSTables, so less compaction work and less read amplification. It also means more data sitting in RAM unflushed, which costs nothing in durability — the commit log already has it — but does mean a longer replay on restart, since recovery has to re-apply every log record written since the last flush.
Why reads still need the Bloom filterRead path›
Levelled compaction bounds a read to one SSTable per level, but L0 is unsorted and a deep tree has six or seven levels, so a single point read can face a dozen candidate files. Without Bloom filters, a read for a key that does not exist would touch every one of them. With a 1% false-positive rate at about 10 bits per key, almost all of those files are eliminated in RAM.
The mechanics — hash functions, the bits-per-key maths, why deletes are impossible — are in the Bloom filter section of the distributed cache guide. The number to carry into this conversation is the memory cost: 10 bits per key. A node in this design holds roughly 3.75 billion records (20B logical keys × N=3, spread over 16 nodes), so the filters alone want about 4.7 GB — RAM that is then not available for the block cache.
Consistency & conflict resolution
§6 ended on an admission: at W = 2 of N = 3 the coordinator will sometimes read two different values for the same key and has to pick one. That is not a bug to be engineered away — it is the direct, intentional consequence of acknowledging before every replica has the write. This section is about what you do with it, and it is the part of the design most likely to be probed hardest, because the wrong answer here loses data silently.
Be precise about the two situations that look identical and are not. A replica that is simply behind has an older version of a write it will eventually receive; there is a right answer and the system just has to deliver it. Two clients that wrote concurrently, each without having seen the other, have produced two equally valid values; there is no right answer, and any mechanism that silently picks one is choosing to lose a write. Telling these apart is the entire job of version metadata.
Last-write-wins, and why the timestamp is a trap
The simplest resolution is to stamp every write with a wall-clock time and keep the larger one. It is O(1), needs no client cooperation, and it is what Cassandra does by default — so it is a defensible answer, provided you say the cost out loud rather than being told it.
The cost is that it cannot distinguish "older" from "concurrent", so every concurrent
write becomes a silently discarded write, and it inherits every clock problem the fleet has. Tens of milliseconds
of skew between two nodes is ordinary under healthy NTP; under a failed NTP daemon, seconds. A write that genuinely happened second
but landed on the node with the slow clock loses — and nothing anywhere reports an error. The client
got its 200 OK. This is the specific failure interviewers are listening for:
LWW makes lost updates invisible.
It is the right choice when writes to a single key are rarely concurrent and the value is a whole-object overwrite — a user profile, a device setting. It is the wrong choice for anything accumulative: a counter, a cart, a set of tags.
Version vectors
The alternative is to track causality instead of time. Each key carries a small map of
node → counter; the coordinating node bumps its own entry on every write. Version X
descends from version Y if every counter in X is greater than or equal to Y's. If neither
descends from the other, the writes were concurrent, and the system knows it.
// Ada and Ben both read the cart at version {A:1}
put cart:42 ["book"] → version {A:1}
// Ada adds a pen. Coordinated by node A, based on {A:1}
put cart:42 ["book","pen"] → version {A:2}
// Ben adds a mug at the same moment. Coordinated by node B, also based on {A:1}
put cart:42 ["book","mug"] → version {A:1, B:1}
// Neither descends from the other: A:2 > A:1, but B:1 > B:0.
// The read returns BOTH — these are siblings.
get cart:42 → [ {["book","pen"], {A:2}},
{["book","mug"], {A:1,B:1}} ]
// The client merges (union, for a cart) and writes back a version
// that descends from both, which collapses the siblings.
put cart:42 ["book","pen","mug"] → version {A:3, B:1}
The famous illustration is Amazon's shopping cart, and the reason it is famous is the trade it makes: DynamoDB's predecessor chose that an item mistakenly reappearing in your cart is a better outcome than an item silently vanishing. Union-merging siblings can resurrect a deleted item; it can never lose an added one. That is a business decision expressed as a data structure, and naming it that way is worth more than reciting the algorithm.
Two honest costs. Version vectors grow with the number of nodes that have ever coordinated a write to the key, so implementations truncate the oldest entries — which can reintroduce false concurrency. And they push work onto the client: every reader must be able to receive several values and merge them. If you cannot change the clients, you cannot use this, and that constraint alone decides the question in many real systems.
CRDTs are the escape hatch worth one sentence. If the value is a type whose merge is mathematically guaranteed to converge — a grow-only set, a PN-counter, an OR-set — the store can merge siblings itself and the client never sees them. Riak shipped these; Redis Enterprise's active-active replication is built on them. They are not general (you must accept the type's semantics), but for counters and sets they remove the client-merge requirement entirely, which is exactly the objection that sinks version vectors.
The three repair mechanisms
Detecting divergence is not enough; replicas have to converge. Three mechanisms do it, at three different timescales, and a complete answer names all three because each covers what the others miss.
| Mechanism | When it runs | What it covers | What it misses |
|---|---|---|---|
| Hinted handoff | Immediately, when a replica is unreachable at write time | Short outages — the common case, and it is cheap | Outages longer than the hint retention; hints lost if the coordinator itself dies |
| Read repair | On every read where replicas disagree | Hot keys, fixed at almost no cost since the data is already in hand | Everything nobody reads — which is most of the dataset |
| Anti-entropy | Scheduled background repair, typically daily or weekly | The whole key space, including cold keys and bit rot | Nothing — but it is expensive, so it cannot run continuously |
Read repair and hinted handoff are easy to describe. Anti-entropy is the one with an interesting data structure behind it, because the naive version — compare every key on every replica — would move the entire dataset across the network to discover that almost nothing differs.
Merkle trees
Anti-entropy without Merkle trees means shipping the dataset to compare it. With them, two replicas that are fully in sync discover that fact by exchanging a single hash.
Tuning consistency per call
Because W and R are request parameters (§5b), one cluster can serve several consistency contracts at
once. The rule is W + R > N for read-your-writes on a single key; below it, you are
choosing eventual consistency deliberately.
| W / R (N = 3) | Guarantee | Cost | Use it for |
|---|---|---|---|
W=1, R=1 |
Eventual only — 2 > 3 is false, so a read can miss a write entirely | Lowest latency, highest availability | Telemetry, view counts, anything idempotent and replaceable |
W=2, R=2 |
Overlap guaranteed — a read always sees the latest acknowledged write | One extra replica round-trip on both paths | The default for this design |
W=3, R=1 |
Same overlap, cheapest possible read | Writes fail if any replica is down | Write-once reference data: config, feature definitions |
W=1, R=3 |
Same overlap, cheapest possible write | Reads fail if any replica is down; every read pays the slowest replica | Ingestion where writes must never be rejected |
The honest caveat about W + R > N. It guarantees that the read set and the write set intersect. It does not give you linearizability, and claiming it does is the most common overreach in this interview. It says nothing about operations across two keys, nothing about ordering between concurrent clients, and it quietly stops holding under sloppy quorum (§6), where the two replicas that accepted a write may not be the two the read consults. If you need real linearizability, you need consensus — Raft or Paxos per partition — and that is a different system with a leader in it.
Why not just use a consensus protocol everywhere?Consistency model›
Raft per partition would remove this entire section: a single leader orders writes, there are no siblings, no read repair, no vector clocks. That is genuinely simpler to reason about, and it is what etcd, Spanner and DynamoDB's internal partitions do.
What it costs is the availability property in §2. A leader-based partition is unavailable for writes during a leader election — typically hundreds of milliseconds to a few seconds, on every leader failure and every deploy. A leaderless design has no such window, because any node can coordinate. You are trading a hard consistency guarantee for the absence of write outages.
Read repair: blocking or background?Read path›
When a read finds a stale replica, the coordinator can push the fix before responding (blocking) or after (background). Blocking read repair makes a subsequent read from that replica correct, and it is what makes monotonic-read guarantees actually hold; it also adds a replica write to the read path, directly to p99.
Background is the usual default, and it is worth being clear about what it does not promise: the client that triggered the repair got the right value, but another client reading the stale replica microseconds later may still get the old one.
read_repair_chance that repaired a sampled percentage of reads; it was
deprecated in 3.0 and removed in 4.0 in favour of a binary read_repair
setting, on the argument that sampling made convergence unpredictable without meaningfully
cutting the cost.How often should anti-entropy run?Operations›
It is bounded below by tombstone retention. Every replica must be repaired at least once
within gc_grace (§7), or a compaction that drops tombstones can let a down
replica resurrect deleted data. If gc_grace is ten days, full repair must
complete inside ten days — not be scheduled inside ten days.
It is bounded above by cost: a full repair reads and hashes the entire dataset on every replica, competing with foreground traffic and with compaction.
Deep-dive: scalability
Scaling this system is mostly uneventful, and that is the point of the choices made in §4 and §5: no leader to become a bottleneck, no routing tier to saturate, and a ring that spreads both data and traffic. What does not scale automatically is everything that involves moving bytes — adding a node, losing one, or crossing a region boundary — because those are bounded by disk and network rather than by request rate.
Each region is a complete cluster that satisfies its own quorum locally, so a normal write never pays a WAN round-trip. The price is that the regions are eventually consistent with each other, and the conflict machinery from §8 is what makes that survivable.
Hot partitions — the one thing replication does not fixL5/L6 depth›
A key's replica set is determined by its hash, so a key that takes a thousand times the average traffic takes it on exactly three nodes while the other nine sit idle. Adding capacity does not help: the new node is somewhere else on the ring.
Reads and writes need different answers, and conflating them is a common stumble. Hot reads are the easy case — put a cache in front of the store, which is what most production deployments of this system actually do and is the subject of the companion guide. If you cannot, you can raise the replication factor for that key range so more nodes can serve R, at the cost of more storage and more write fan-out.
Hot writes have no cheap fix, because every copy must receive every write
no matter how many copies there are. The available move is to change the key so it is no
longer one key: salt it into counter:42#0 … #15, write to a
random shard, and sum the shards on read. You have converted a write hotspot into a
sixteen-way read fan-out, which is usually the right trade for a counter and the wrong one
for anything that must be read at single-key latency.
Adding capacity: bootstrap is a data-movement problemOperations›
Adding a node takes over a fraction of the ring, and with vnodes that fraction comes in small pieces from every existing node at once, which is what makes the transfer fast. But it is still a transfer: a node that will hold 4 TB has to receive 4 TB before it is useful, and it must do so without starving the live traffic on the nodes sending it.
The sequence that matters: the joining node streams the SSTables for its new ranges while also receiving every new write for those ranges, so it never falls behind the live stream. It does not serve reads until streaming completes — serving early would mean returning "not found" for keys it simply has not received yet, which in a store is a wrong answer rather than a cache miss. Only then does it join the read path and the previous owners drop the ranges.
Scaling reads without adding nodesRead path›
Before buying hardware, there are three layers of memory between a read and the disk, and they fail in different ways. The block cache holds recently read SSTable blocks and is the one that matters; a well-sized block cache is why a store with 4 TB on disk can still serve most reads from RAM. The page cache below it does the same job less precisely, which is why some engines deliberately keep their own block cache small and lean on the OS. A row cache holding whole values sounds better and usually is not: one write invalidates the entire row, so on anything but a read-only workload it churns.
Lowering R is the other lever and it is free in latency terms: R=1 skips waiting for a second replica entirely. It is only available for keys where you have already decided eventual consistency is acceptable (§8), and quietly dropping R to fix a latency graph is how a system loses read-your-writes without anyone noticing.
Multi-region active-activeL7/L8 depth›
Both regions accept writes and replicate asynchronously, so a user is served from the nearest region and never pays a 70 ms WAN round-trip on the write path. The decision that makes this work is quorum scope: W and R are satisfied by replicas within the local region. A cross-region quorum would give you a much stronger guarantee and a write latency measured in hundreds of milliseconds, and would make either region's failure an outage for both.
What you have accepted in exchange is precise: a write in US-West is not visible in US-East for 50–200 ms, and two writes to the same key in two regions inside that window are concurrent — not ordered, however far apart their timestamps look. This is where last-write-wins does its worst damage, because the resolution is decided by the clock skew between two data centres. If the data is genuinely written from both regions, version vectors or CRDTs (§8) are not optional.
Failure modes & edge cases
A cache's failure modes are mostly about latency: something misses, something falls through, something
gets slow. This system's failure modes are about data, which is why they are worth
more in an interview and why the answers have to be specific. The ones that matter most are the quiet
ones — the failures where every request returns 200 OK and the data is wrong anyway.
| Scenario | Problem | Solution | Level |
|---|---|---|---|
| Single node crashes | No data is lost at RF = 3, but every range that node owned is down to two copies and stays there until a replacement has streamed the data — hours, not seconds | Writes continue at W = 2 on the surviving replicas; the coordinator stores hints for the dead node. Replace or rebuild promptly, and treat time spent below RF as the durability metric, not RF itself | L4 |
| Acknowledged write lost on power failure | The commit log was write()-n but the bytes sat in the drive's volatile cache.
The client holds a 200 OK for a write that no longer exists |
fsync before acknowledging, on drives with power-loss protection or with the
write cache disabled. This is why durability is a property of the device, not just
the protocol — and why W = 2 across two machines is the real defence |
L5 |
| Compaction falls behind | SSTable count grows → read amplification grows → latency grows, while the node is already IO-bound, which makes compaction fall further behind. It is a feedback loop and it does not self-correct | Budget compaction throughput rather than leaving it best-effort; alarm on pending compaction bytes and SSTable count, not on latency (latency tells you too late); shed writes from the node as a documented procedure (§7) | L5 |
| Hot partition | One key takes 1000× average traffic on exactly its three replicas; the other nodes are idle and adding capacity does not help | Hot reads: cache in front, or raise RF for that range. Hot writes: salt the key into N shards and aggregate on read — there is no fix that keeps it one key (§9) | L5 |
| Network partition (split brain) | The cluster splits; both sides keep accepting writes for the same keys and diverge | A strict quorum makes the minority side fail rather than diverge — availability traded for correctness. If you run a sloppy quorum for availability, say so and accept that both sides take writes and §8's conflict resolution is what reconciles them | L6 |
| Deleted data comes back | A replica down longer than gc_grace returns after compaction has dropped the
tombstones, and anti-entropy propagates its stale live value back to everyone |
Never rejoin a node that has been down longer than gc_grace — rebuild it from
scratch. Alarm on time since last successful repair per range, not on the repair
job's exit code (§8) |
L6 |
| Clock skew silently loses writes | Under last-write-wins, the node with the fast clock wins regardless of what actually happened last. A genuine write disappears and nothing reports an error | Monitor clock offset across the fleet and alarm on it as a correctness signal, not a hygiene one. Where lost updates are unacceptable, use version vectors or conditional writes instead of timestamps (§8) | L6 |
| Disk fills up | Compaction needs free space to write the merged output before deleting the inputs, so a full disk is the one state from which the node cannot dig itself out | Alarm at 50–60% used, not 90%: levelled compaction only needs headroom for the levels being merged, but the threshold has to cover a fallback to size-tiered, which can need room for a second copy of the largest tier. Keep the node write-rejecting but readable rather than letting it wedge | L6 |
| Correlated failure: an AZ or rack goes down | Three replicas that happen to share a rack are one replica as far as a rack failure is concerned. The range is unavailable, and if the failure is destructive it is gone | Rack- and AZ-aware replica placement, enforced by the placement strategy rather than by luck (§5). Verify it by asking where the three copies of a given key actually are, not by reading the config | L7/L8 |
| Repair storm after a large failure | Losing several nodes triggers simultaneous bootstrap streaming, anti-entropy and hint replay, saturating the network the surviving nodes need to serve traffic | Throttle streaming, hint replay and repair independently, and stagger them. Recovery competing with production is how a recoverable incident becomes an outage | L7/L8 |
| Silent data corruption | A bit flips on disk or in flight. The value is returned successfully and is wrong; read repair will happily propagate it to the healthy replicas | Per-block checksums in the SSTable format, verified on every read, so corruption surfaces as an error instead of as data. Treat a checksum failure as "rebuild this replica", never as "retry" | L7/L8 |
The unifying point, if you have one sentence left: in a cache, a failure costs you latency and the correct value is still somewhere else. Here, the correct value is only what the replicas agree it is — so every failure mode above is really a question about whether the system can still tell the difference between stale and authoritative.
How to answer by level
The interview differentiator: almost every candidate can draw a ring of nodes with three copies of each key. What separates levels is whether you can say what an acknowledgement actually promises, what happens to the write that did not reach the third replica, and who wins when two replicas disagree — without being asked.
L4, SDE IIEntry level›
- Clear API: get / put / delete, with versions returned to the client
- Single node done properly: commit log written and fsynced before the ack, then an in-memory table
- Knows why data is on disk and not only in RAM — restart must not lose writes
- Names replication as the answer to a node dying, and says RF = 3
- Recognises that a delete has to be recorded, not just applied
- Says "replicate to three nodes" but cannot say how many must answer
- Assumes replicas are always in sync; no notion of a replica being behind
- Cannot name a partitioning scheme beyond "hash mod N"
- Treats fsync as a detail rather than as the durability boundary
L5, Senior SDESenior›
- Consistent hashing with virtual nodes, and can explain what moves when a node joins or leaves
- Quorum reasoning: W + R > N, and why the coordinator waits for W of N rather than sending to W
- Explains the LSM write path end to end and why it is fast on SSD
- Names hinted handoff and read repair, and what each one covers
- Knows a delete is a tombstone, and that tombstones cannot be dropped immediately
- Tunes W and R per workload instead of picking one setting for the cluster
- Says "last write wins" without naming clock skew or silent lost updates
- Cannot distinguish a replica that is behind from two writes that were concurrent
- Compaction is "background cleanup", with no read/write/space amplification trade
- Claims W + R > N gives strong consistency or linearizability
- Single region only; no rack or AZ awareness in replica placement
L6, Staff SDEStaff›
- Version vectors: can show a worked sibling example and say who merges them
- Chooses a compaction strategy from the read/write mix and states the write amplification it costs
- Names the compaction-backlog feedback loop and what to alarm on
- Anti-entropy with Merkle trees, and ties the repair interval to
gc_graceso deleted data cannot return - Rack- and AZ-aware placement, and quantifies time-below-RF as the real durability number
- Handles conditional writes honestly: knows CAS is not free on a leaderless store
- Capacity planning tied to §3, including compaction headroom and Bloom-filter RAM
- Multi-region is "replicate asynchronously" with no stated RPO
- No make-vs-buy analysis (DynamoDB / Cassandra / ScyllaDB vs building)
- Cannot frame the leaderless-vs-consensus choice as a business decision
L7/L8, Principal / DistinguishedPrincipal+›
- Leads with "should we build this at all, or run DynamoDB / Cassandra?" and answers it with operational cost, not preference
- Reverse-engineers the architecture from SLOs and an error budget rather than arriving at SLOs afterwards
- States the durability target as a number and shows what RF, repair cadence and AZ placement have to be to reach it
- Quotes a non-zero RPO for multi-region async replication instead of implying there is none
- Proposes the hybrid: leaderless by default, consensus for the few keys that need it
- Has a migration story — dual-write, backfill, verify, cut over — not just a target state
- Cost and operational ownership are first-class constraints: who runs repair at 3 a.m., and what it costs per TB-month
- Forward planning: what breaks at 10× the data, and is it the same thing that breaks at 10× the writes?
- Names real systems and their actual limits — DynamoDB's partition throughput ceiling, Cassandra's tombstone and repair pain, ScyllaDB's shard-per-core model
Classic probes table
| Probe | L4 answer | L5/L6 answer | L7/L8 answer |
|---|---|---|---|
| "What does a successful write actually guarantee?" | "It's saved" — no mention of how many replicas or of fsync | W replicas have it in an fsynced commit log. Not all N. The remaining replica gets it via hinted handoff, and until then a R=1 read from that replica can miss it | Turns it into a durability budget: what is the probability of losing W machines simultaneously, what is time-below-RF after a failure, and what RPO does async cross-region replication add on top |
| "Two replicas return different values. Which is right?" | "Take the newest timestamp" | Compare version metadata first: if one descends from the other, it wins and the stale replica is read-repaired. If neither does, the writes were concurrent — LWW would silently discard one, so return siblings or use a CRDT | Frames it as a product decision: for a cart, resurrecting an item beats losing one, so merge; for a profile, LWW is fine; for a balance, neither is acceptable and that key needs consensus. Names the clock-skew exposure explicitly |
| "A node has been down for two weeks. Can you just bring it back?" | "Yes, it'll catch up from the others" | No — if it was down longer than gc_grace, compaction has dropped tombstones
elsewhere and rejoining resurrects deleted data. Rebuild it from scratch instead |
Treats it as an operational invariant, not a judgement call: automated refusal to rejoin past the threshold, alarms on time-since-last-repair per range, and a documented rebuild runbook so nobody has to make the call at 3 a.m. |
| "Reads got slow and nothing changed. Why?" | "More traffic, add nodes" | Read amplification. Compaction is behind, so more SSTables per lookup — check pending compaction bytes and SSTable count, not just latency. Alternatively the block cache no longer holds the working set, or Bloom filters are being crowded out of RAM | Points at the feedback loop: an IO-bound node makes compaction slower, which makes it more IO-bound. Fixes are capacity decisions made in advance — reserved compaction throughput, leading indicators rather than latency alarms, and a load-shedding procedure |
| "Why not use a database?" | "It wouldn't scale" | A B-tree engine pays a random write per update and a relational store pays for transactions and indexes this workload never uses. At 750k ops/sec of point access with no joins, an LSM store on a hash-partitioned ring is a much better fit | Inverts the question: what do we lose? Transactions, secondary indexes, ad-hoc queries and a mature operational ecosystem. Argues that the scale and access pattern justify it — or concedes it does not and picks the boring option |
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: why the acknowledgement waits for two replicas and not three, why the cluster provisions 40 TB for 10 TB of data, why repair speed matters more than the replication factor.
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 entire write budget is 10 ms, so the log goes on NVMe” 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 and write SLO | < 10 ms p99, both | The budget every number below is measured against. It is ten times a cache's budget precisely because durability and quorum are on the path — and that gap is the whole reason these are two different systems (§2) |
| fsync on NVMe with power-loss protection | ~0.1–1 ms | The only synchronous disk operation on the write path, and the reason group commit exists. One fsync per write caps a node near 1 K writes/sec; batching a few hundred microseconds' worth makes that ceiling disappear (§7) |
| Round trip inside one data centre | ~0.5 ms | That quorum costs one hop, not W hops — the coordinator fans out in parallel. What it does cost is the slower of two replicas rather than a typical one, which is where the gap between mean and p99 comes from (§6) |
| Cross-AZ round trip | ~1 ms | The price of a durability guarantee that survives losing a rack. Three replicas in one rack is a replication factor of one as far as a rack failure is concerned, so this ~0.5 ms of extra latency is not optional (§5) |
| NVMe random read, 4 KB | ~0.1 ms | Why read amplification is a latency number and not an abstraction. Each SSTable a read must consult is another ~0.1 ms, so a compaction backlog turns directly into p99 (§7) |
| Spinning-disk seek | 4–10 ms | That the device choice is part of the design. One seek is most of the budget, which is why the commit log and the hot levels live on NVMe even when the cold levels do not (§2) |
| Cross-region replication lag | 50–200 ms | That multi-region is an availability story, not a consistency one — and that it carries a non-zero RPO. Two writes to one key inside that window are concurrent no matter what their timestamps say (§9) |
| Availability target | 99.99% ≈ 52 min/year | How much a failure may cost. About 60 seconds of budget per week means nothing on the recovery path can wait for a human; it also rules out a design with a leader election on the write path (§2, §10) |
Capacity and throughput: the numbers that decide how many boxes
These answer “how big is this cluster” — and the important ones are the multipliers, not the inputs. Quoting logical data and logical write rate is the most common way to arrive at a cluster three times too small.
| Number | Value | What it settles |
|---|---|---|
| Peak traffic | 500 K reads/sec 250 K writes/sec |
That this is not a read-dominated system. At 2:1 the write path deserves as much design attention as the read path, which is the opposite of the cache case at 20:1 (§3) |
| Logical → provisioned disk | 10 TB → 30 TB → 36–45 TB | The two multipliers everyone drops: RF = 3, then 1.2–1.5× space amplification for obsolete versions and tombstones awaiting compaction. The answer to “how much disk?” is four times the data, not one (§3, §7) |
| Nodes at 4 TB usable NVMe | ~12 minimum, plan ~16 | The cluster size — but quote the inputs, not the output. The headroom is not padding: a cluster at 90% disk cannot compact, and a node that cannot compact cannot recover (§3, §10) |
| Write amplification, levelled | 10–30× | The multiplier that decides the hardware bill. 250 K writes/sec × 500 B × RF 3 × 10 ≈ 3.75 GB/s of disk writes cluster-wide, about 235 MB/s per node sustained — a real fraction of an NVMe drive, competing with the reads you just optimised (§7) |
| Space amplification by strategy | ~1.1× levelled up to 2× size-tiered |
Why the compaction choice shows up in the disk budget as well as in latency. Size-tiered needs room for a second copy of the largest tier during the merge, which is a free-space alarm, not an average (§7, §10) |
| Bloom filter memory | 10 bits/key → ~4.7 GB/node ~75 GB cluster-wide at RF 3 |
The one RAM cost that scales with the dataset rather than with traffic, and the one you least want to cut — the filters are what stop a point read touching every level. Note it is 60 B stored records, not 20 B logical keys (§7) |
| RAM per node | ~32–64 GB | That RAM here holds MemTables, Bloom filters and the block cache — not the dataset. Size the three together; tuning one and discovering the others shrank is a common way to make reads slower by adding memory (§9) |
| Virtual nodes per physical node | 128–256 | Why rebalancing is fast and even. A lost node's ranges come from every surviving node at once rather than from its two neighbours, which is what makes rebuild time a function of cluster width (§5) |
| MemTable size | 64–256 MB | A compaction knob wearing a memory knob's clothes. Bigger means fewer, larger flushes and less compaction work; it also means a longer commit-log replay on restart (§7) |
Durability and repair: the numbers nobody quotes and everybody should
| Number | Value | What it settles |
|---|---|---|
| N / W / R | 3 / 2 / 2 | That consistency is a per-request knob, not a cluster constant. W + R > N guarantees the read set and write set intersect — it does not give linearizability, and saying so unprompted is worth more than the formula (§8) |
| Time below full replication | ~6 hours | The number that actually determines durability. 4 TB streamed at 200 MB/s without starving live traffic is roughly six hours at two copies — so rebuild speed, not RF, is the lever that moves the risk (§5, §9) |
| Probability of losing all three copies | order 10−10/year | Where “eleven nines” claims come from: a few percent annual node-failure rate, cubed, narrowed by a six-hour repair window. Quote it as an order of magnitude and immediately name what it ignores — correlated failure, which is the term that actually dominates (§10) |
| Tombstone grace period | ~10 days | The hard deadline on repair. Every range must be repaired inside this window or compaction can drop tombstones that a returning replica will overwrite with stale live data. It is a correctness constant disguised as a cleanup setting (§7, §8) |
| Cross-region RPO | up to ~200 ms of writes | What losing a region actually costs. Async replication means a non-zero RPO by construction; quoting the number is far stronger than implying there is none (§9) |
| Clock skew under healthy NTP | 1–100 ms | Why last-write-wins is a data-loss mechanism and not just an imprecision. Any two writes closer together than the skew are ordered by the clocks rather than by what happened, and the loser disappears with no error (§8) |
How to use these in the room. Three habits separate a number that lands from one that doesn't:
- Round hard, then move. 3.75 GB/s and “call it 4 gigabytes a second” buy the identical conclusion. Spending twenty seconds on digits signals you have mistaken arithmetic for the answer.
- Lead with the multiplier, not the input. “10 TB of data” is the least interesting number in this design. “10 TB becomes 40 TB provisioned and 250 K writes/sec becomes 7.5 M disk writes/sec” is the sentence that shows you understand what replication and compaction cost.
- 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 durability figure you cannot defend. If you assert eleven nines, be ready for “from what?” — the honest answer is that it is an independence calculation over node failure rates and repair time, that the assumption of independence is the weakest part of it, and that a correlated failure such as an AZ loss or a bad deploy dominates the arithmetic by orders of magnitude. Saying that yourself is stronger than being told it.
Going deeper, reference sections
What to actually use, and when not to build
At L6 and above the strongest opening move on this question is often not a diagram. It is: "we almost certainly should not build this — here is what I would run instead, and here is the specific thing that would make me change my mind." Everything in this article is a description of systems that already exist and have absorbed a decade of operational scar tissue each. Rebuilding one is a multi-year commitment whose main deliverable is repair tooling.
So know the landscape well enough to pick from it. The four systems below all implement most of this design; they differ in the parts you now know are expensive.
| Dimension | DynamoDB | Cassandra | ScyllaDB |
|---|---|---|---|
| Replication model | Leader per partition internally, exposed as eventual or strongly consistent reads | Leaderless, tunable W and R exactly as described here | Leaderless, Cassandra-compatible protocol |
| Conflict resolution | Hidden — the leader orders writes, so there are no siblings to merge | Last-write-wins by timestamp, per cell. Clock skew is your problem | Same as Cassandra |
| Conditional writes | Cheap: ConditionExpression, decided by the partition leader alone |
Expensive: lightweight transactions run Paxos, roughly 4× the round trips | Expensive, same mechanism |
| Compaction | Invisible — you do not operate it | Yours to tune, and the most common source of production pain | Shard-per-core C++ runtime; far better at keeping compaction off the foreground path |
| Repair / anti-entropy | Invisible | You schedule it, you monitor it, and a repair quietly failing for two weeks is how deleted data returns | Same responsibility, with better throughput control |
| Scaling | Automatic, per-partition throughput ceiling is the wall you hit | Add nodes and stream; linear, but bootstrap is hours of data movement | Same, generally faster per node so fewer nodes overall |
| Cost shape | Per request and per GB-month; excellent at low scale, expensive and predictable at high scale | Hardware plus the engineers who run it — cheaper per byte, only if you already have the team | Fewer nodes for the same load, so lower hardware cost at similar operational cost |
Interview move: do not recite the table. Pick from it with a requirement in hand. “We need conditional writes on hot keys and we have no storage-operations team, so DynamoDB — the per-request cost is real but it is cheaper than hiring for compaction and repair. If our access pattern later concentrates on a few partitions hard enough to hit the per-partition throughput ceiling, that is the trigger to revisit, and Scylla would be the move because it is the same data model with the ceiling removed.” Requirement, choice, and a named trigger to change your mind.
When building it is the right answer
There are real cases, and they are narrower than people expect. Name a specific one rather than gesturing at scale:
- The managed option's pricing inverts at your scale. Per-request pricing that is trivial at 10 K QPS is a large line item at 500 K QPS sustained. This is the most common honest reason, and it is an arithmetic argument, not an engineering one.
- You need a merge the store must perform itself. If clients cannot be changed to resolve siblings, and the value is a type with a real CRDT merge, a store that converges internally is a capability none of the above offer in the form you need.
- Placement is a hard constraint. Data residency that requires specific keys to live in specific jurisdictions, enforced at the partitioner, is genuinely difficult to bolt on.
- You are the cloud provider. Somebody has to build it.
What is not a reason: "our workload is special", "we want full control", or "it would be faster". A candidate who says the first two unprompted and then argues past them is more convincing than one who never raises the question.
The adjacent question worth being ready for: "why not just shard Postgres?" It is a better answer than people expect. You get transactions, secondary indexes, ad-hoc queries and an ecosystem, and application-level sharding over a handful of Postgres instances serves a great deal of production traffic. It breaks down at the point where resharding becomes routine, and where the write path starts paying for B-tree page writes and indexes this workload never queries. Say where the line is rather than dismissing the option — that is the answer that sounds like someone who has run both.
Observability & production operations
L6+ candidates are expected to own the system end to end, and this system has an unusual property that makes that harder than it sounds: its worst failures do not show up as errors. A cluster silently losing writes to clock skew, or quietly resurrecting deleted rows because repair has been failing for a fortnight, looks perfectly healthy on a latency dashboard. So the signals worth instrumenting are not the obvious ones.
Key metrics to monitor
| Metric | Target | Alert if… | Implication |
|---|---|---|---|
| Time since last successful repair, per range | < half of gc_grace |
Any range exceeds 70% of gc_grace |
The single most important alarm in this system, and the one most often missing. Past
gc_grace, compaction may drop tombstones that an unrepaired replica will
overwrite with stale live data — deleted records return. Alarm on range freshness, never on
the repair job's exit code (§8) |
| Pending compaction bytes / SSTables per table | Flat in steady state | Monotonically rising for > 30 min | The leading indicator for read latency. Compaction is losing, which raises read amplification, which loads the node further, which makes compaction lose harder. By the time latency alarms, you are inside the feedback loop rather than ahead of it (§7) |
| Ranges below replication factor | Zero | Non-zero for > 1 hour | Durability is a function of time spent below RF, not of RF. This metric is the durability SLO in a form you can actually graph (§9) |
| p99 read latency | < 10 ms | p99 > 20 ms | Usually read amplification (see compaction above), a block cache no longer holding the working set, or R = 2 waiting on a slow replica. Three different fixes — check SSTables per read before touching anything else (§7, §9) |
| p99 write latency | < 10 ms | p99 > 20 ms | Either fsync is stalling on a saturated commit-log device, or one replica is slow and W = 2 is waiting on the slower of two. Alert separately from reads; the causes barely overlap (§6) |
| Hint queue depth and oldest hint age | Near zero | Depth growing, or age approaching the hint window | A replica has been unreachable long enough that hinted handoff is about to give up. Past that point the only path back to consistency is anti-entropy, which runs on a much slower schedule (§8) |
| Read repair rate | Low and stable | Sustained rise | Replicas are chronically diverging rather than occasionally. Something is dropping writes — an overloaded node, a flapping link, or a partition nobody declared (§8) |
| Clock offset across the fleet | < 50 ms | Any node > 200 ms, or NTP unsynchronised | Under last-write-wins this is a correctness alarm wearing a hygiene alarm's clothes. A skewed node silently wins conflicts it should lose, and the discarded writes are never reported anywhere (§8) |
| Disk utilisation | < 55% | > 60% | Compaction must write merged output before deleting its inputs, so a node that runs out of space cannot compact its way out. Alarming at 90% is alarming after the point of no return (§10) |
| Coordinator timeouts by consistency level | Near zero | Any sustained rate at quorum | Quorum unavailability, broken out by level so you can see whether it is the strict callers hurting or everyone. Remember that a timed-out write is undetermined, not failed (§5b) |
Safe operations playbook
Rolling restart and upgradeOperations›
One node at a time, and the rule that governs everything is: never take down two replicas of the same range. With vnodes, any two nodes probably share ranges, so "one at a time" is not a convenience, it is the correctness constraint. Before stopping a node, drain it from the coordinator pool, flush the MemTable so restart does not replay a large commit log, then stop.
After it returns, the gate is not "the process is up". It is: commit-log replay complete, hints for that node replayed, and pending compactions back to baseline. Only then move to the next node. Rushing this is how a routine upgrade becomes a repair storm.
Replacing a dead node — the decision that is not a judgement callIncident playbook›
There are two procedures and picking the wrong one corrupts data. If the node has been down
for less than gc_grace, it can rejoin: hints replay, repair fills the
gaps, and it is back. If it has been down for longer, it must be wiped and
bootstrapped as a new node, because its local data may contain rows whose tombstones have
already been compacted away elsewhere — rejoining propagates deleted records back to the
healthy replicas.
This should not be a decision someone makes at 3 a.m. Automate the refusal: a node that has been absent past the threshold declines to rejoin, and the runbook has exactly one path from there.
Compaction backlog: how to actually dig outIncident playbook›
Symptoms: read p99 climbing, SSTables per read climbing, disk IO pinned. The instinct is to give compaction more throughput, and on a node that is already IO-saturated that makes reads worse before it makes them better.
The order that works: take the node out of the read path first so its latency stops mattering, then let compaction have the device. Writes can continue — they are sequential and cheap — unless free space is the binding constraint, in which case stop those too. Put it back only when SSTable count is at baseline, not when it is merely falling.
A bad write reached productionIncident playbook›
Worth contrasting with a cache, where the answer is to flush the affected keys and let them repopulate from the source of truth. Here there is no source of truth to repopulate from. The bad value is the data, it is durable, it has been replicated three times, and no TTL will expire it.
So the recovery paths are narrower: overwrite the affected keys with correct values if you can reconstruct them, or restore from a snapshot plus commit-log replay to a point in time before the bad write. Both require knowing the affected key set, which is why key naming conventions (§5b) stop being a style question the first time you need to enumerate a blast radius.
The L7/L8 framing: an L7 candidate treats observability as a design constraint and
asks what the SLO should be on. Latency is easy: put it on the user-visible read and write
p99, at < 10 ms, and spend the error budget deliberately. Durability is the interesting one,
because you cannot measure it directly — a system that has lost no data and a
system that is about to look identical. So the durability SLO has to be expressed through its
inputs: ranges below RF and for how long, repair freshness against gc_grace, clock
offset across the fleet, and backup restore time verified by actually restoring. Saying "we alarm
on the things that would make data loss possible, because data loss itself is only observable
afterwards" is the sentence that separates someone who has operated one of these from someone who
has designed one.
How the pieces connect
Every architectural decision in this article traces back to a small number of commitments made early on. The chain is worth assembling in one place:
- 1 NFR: no acknowledged write is lost (§2) → the ack must come after the record is on stable storage, on more than one machine → fsynced commit log plus W = 2 of N = 3 (§6) → group commit to make the fsync affordable (§7)
- 2 Write budget of 10 ms on SSD (§2) → a random in-place update costs a seek and a read-modify-write → LSM-tree: every write is a sequential append (§7) → the cost moves to background compaction, which becomes the thing you operate (§7, ★ ops)
- 3 Availability 99.99% with no write outages (§2) → a leader election on the write path spends the budget → leaderless: any node coordinates (§4) → the price is that replicas can disagree, so conflict resolution is mandatory rather than optional (§8)
-
4
Acknowledging at W < N (§6) → a replica is allowed to be behind at any moment → hinted handoff, read repair and anti-entropy (§8) → repair freshness against
gc_gracebecomes a correctness alarm, not a hygiene one (★ ops) -
5
Deletes must survive a replica returning from the dead (§5b) → a delete is a tombstone, not a removal → tombstones cannot be compacted away eagerly (§7) → a node down longer than
gc_gracemust be rebuilt, never rejoined (§10) - 6 10 TB of data (§3) → replication and space amplification are the dominant multipliers → ~40 TB provisioned, ~16 nodes, 3.75 GB/s of disk writes (§3, §12) → compaction strategy is a hardware-bill decision as much as a latency one (§7)
- 7 Global users, local quorums (§9) → cross-region replication is asynchronous → two regions writing one key are concurrent by definition (§8) → a non-zero RPO you quote as a number rather than imply away (§12)
- Rate Limiter System Design, atomic Redis operations, distributed race conditions, and multi-tier quota enforcement
- URL Shortener System Design, hash encoding tradeoffs, database sharding strategies, and viral key mitigation
- Web Crawler System Design, Bloom filter deduplication, politeness throttling, and distributed frontier design
- Twitter/X Feed System Design, fan-out write amplification, hybrid push/pull strategy, and celebrity threshold design
- Notification Service System Design, multi-channel delivery, idempotency keys, and priority queues at scale
- Search Autocomplete System Design, Trie data structures, prefix caching, and read-heavy scale strategies
- Distributed Cache System Design — consistent hashing, eviction and TTL policy, hot keys, and cache-aside vs write-through
- Chat System (WhatsApp) System Design, WebSocket management, transient vs persistent storage, and read receipts
- Video Streaming (YouTube) System Design, ABR streaming, CDN distribution, and metadata management
- Distributed Message Queue System Design, Kafka partition tuning, exactly-once delivery, and geo-replication
- File Storage (Dropbox / Google Drive) System Design, chunking, delta sync, conflict resolution, and global deduplication
- Ride-Sharing System Design (Uber / Lyft) — geohashing, WebSocket-driven location tracking, and ETA prediction
- Payment Processing System Design — idempotency keys, exactly-once semantics, and append-only ledger models
- Top-K Leaderboard System Design — Redis sorted sets, approximate counting, and stream aggregation
- Airbnb Booking & Reservation System — inventory locks, double-booking prevention, and async elasticsearch sync
- Photo-Sharing Feed System Design — image pipelines, CDN delivery, and social graph scaling
- Proximity Search System Design (Yelp / Google Places) — geohash indexing, quadtree partitioning, and Bayesian review ranking
- Online Judge System Design — secure sandboxing, execution queues, and worker scaling
- Collaborative Document Editing (Google Docs) System Design — operational transformation vs CRDTs, ownership leases, and offline merge
- Ticket Booking & Flash-Sale (Ticketmaster) System Design — seat-hold contention, optimistic vs pessimistic locking, and virtual waiting rooms
- Google Maps System Design — road-graph routing, contraction hierarchies vs customisable preprocessing, vector tiles, and live-traffic ETAs
- Object Storage (Amazon S3) System Design — erasure coding across zones, durability math, and a range-partitioned metadata index
- Social Post Search System Design — per-viewer visibility filtering, over-fetch math, and fan-out tail latency