Lesson 7 section 7.4 left a question open: when the network is cut, neither node knows whether the other has died or has merely lost contact β from the inside, those two situations are completely identical. This lesson formalises that question, and more importantly, points out the part CAP leaves out but that you meet every day.
This lesson's lab creates a real partition with docker network disconnect,
and the answer turns out to be neither "an error" nor "stale data" but three different things across three
consecutive calls: the first request hangs for 30 seconds, the next two error out in
29β40 ms, and writes to the primary keep succeeding normally. The consistency model you
think you have and what the system actually does when the network breaks are usually two
different things.
node sysdesign-quorum.js β a deterministic simulation, same
seed for the same result, 100,000 rounds per configuration. No Docker needed; you can repeat it in
seconds.The lab parts in sections 9.1 and 9.3 ran on an Apple M1 Max, macOS 26.5.2, Docker 29.6.2, PostgreSQL 18.3 primary + replica, three app replicas. The partition is created with
docker network disconnect β a real partition at the network layer, not a simulation.
9.1 Reading CAP correctly: P is not a choice
CAP is usually told as "pick two of three: Consistency, Availability, Partition tolerance". That telling leads to a wrong conclusion from the very start, because P is not something you pick. The network will be cut: a cable is severed, a switch dies, a region loses connectivity, or a container simply leaves the network. That is physical reality, not a line in a config file.
The correct statement: while a partition is happening, you must choose between C and A. Outside of that, the question does not exist. Which is why the notion of "a CA system" is a confusion β it only means "a system that has never met a partition", which is to say a system that has not run long enough.
One: the CβA trade-off only exists during a partition. Throughout the other 99.9% of the time you can be strongly consistent and lose nothing in availability.
Two: the choice is not for the whole system but for each operation. Eventual is perfectly sensible for a view counter; it is not for debiting an account. One system, two operations, two different consistency levels β and that is correct design, not inconsistency.
Lab: a real partition, and three different behaviours
Theory says "choose C or A". But when a partition really happens, what does your system do? The answer is usually not what you think. Cut the replica off the network while the app is reading from it:
cd blog/sysdesign/sysdesign-lab
docker compose --profile replica up -d
NET=sysdesign-lab_default
# 1) Normal: reading from the replica works
curl -s "http://localhost:3001/rww?id=7"
# {"wroteVersion":1,"readVersion":1,"readFrom":"replica","stale":false}
# 2) CUT the replica off the network β a REAL partition at the network layer
docker network disconnect $NET sysdesign-lab-postgres-replica-1
# 3) What does the app do?
curl -s -m 30 "http://localhost:3001/rww?id=7" # HANGS 30 SECONDS, client gives up
curl -s -m 30 "http://localhost:3001/rww?id=7" # 40ms: ENOTFOUND postgres-replica
curl -s -m 30 "http://localhost:3001/rww?id=7" # 29ms: ENOTFOUND postgres-replica
# 4) But what about WRITES to the primary? The primary was not cut off.
docker compose exec -T postgres psql -U lab -d lab -tAc \
"UPDATE profiles SET version=version+1 WHERE id=7 RETURNING version;"
# 6 Β· UPDATE 1 Β· took 135ms β STILL SUCCEEDS
# 5) Reconnect. --alias is MANDATORY, otherwise DNS never comes back.
docker network connect --alias postgres-replica $NET sysdesign-lab-postgres-replica-1
curl -s "http://localhost:3001/rww?id=7" # {"readVersion":9,"stale":false}
curl -s "http://localhost:3001/rww?id=7" # {"readVersion":9,"stale":true} β catching up
| Operation during the partition | Measured result | What it means |
|---|---|---|
| Read from the replica, first request | Hangs 30 seconds, then the client gives up | The already-open TCP connection receives neither data nor a FIN. The app has no read timeout, so it waits forever |
| Read from the replica, later requests | Error after 29β40 ms: ENOTFOUND |
The container has left the network so DNS cannot resolve β a fast failure, easy to handle |
| Write to the primary | Succeeds, 135 ms | The primary was not cut off. The system is partially available: writes fine, reads broken β a state CAP has no name for |
| Immediately after healing the network | stale: true on the second read |
The replica is replaying the WAL it missed β stale data is the normal state of this phase |
curl's -m 30: 30 seconds for one request.Which means if you set no timeout, you have chosen A by accident: the system does not reject requests, it merely holds on to them β and every held request is a connection, a thread, a slot in a pool. Enough of them and the pool is exhausted, at which point parts of the system with nothing to do with the replica stop serving too. This is exactly the cascade failure Lesson 17 digs into, and the reason Lesson 4 section 4.5 treats the timeout budget as a design item rather than a default parameter.
The second row, by contrast, is good news: when the failure is fast and explicit (29 ms, a specific error name), you get to choose β return an error, read the primary, or serve stale data from cache. Fast failure gives you the decision; hanging forever does not.
9.2 PACELC β the part CAP leaves out, and the part you meet daily
CAP only speaks about the moment of a partition. But partitions are rare; the other 99.9% of the time the network is perfectly fine β and throughout that time there is still a trade-off, CAP just does not mention it. PACELC adds exactly that part:
if (P) then (A or C) else (L or C) β if there is a partition, choose between availability and consistency; otherwise (Else), choose between latency and consistency.
The second half is the half you meet every day, and you already met it in Lesson 7 without naming it: reading from a replica is fast but may be stale; reading from the primary is fresh but slower and consumes the primary's resources. That is exactly "L or C" β the network is not cut at all, every node is healthy, and you still have to choose.
| A real decision already made in this series | Choosing L (fast) | Choosing C (fresh) | The measured price |
|---|---|---|---|
| Read from a replica or the primary (Lesson 7) | Replica | Pin to the primary after a write | 87.21% stale reads β β4.6% throughput |
synchronous_commit level (Lesson 7) |
local |
remote_apply |
0.468 ms β 0.811 ms per write; tps 17,096 β 9,867 |
| Cache TTL (Lesson 5) | Long TTL | Short TTL or explicit deletion | Staleness window β database load rising with (1βh) |
s-maxage at the edge (Lesson 6) |
Cache long at the edge | Revalidate every time | the origin handles 4 of 208,173 requests β data can be stale up to s-maxage |
How to use it in practice: for every read path in your system, answer one question β does this path choose L or C? If you cannot answer, it is choosing L (because L is the default of every architecture with a cache and replicas), and you ought to know that is what you are choosing.
9.3 The spectrum of consistency models
"Consistency" is not an on/off switch but a range. Each stronger level eliminates one kind of anomaly the user can perceive β and its price is latency or availability.
In the lab, "eventual" was 0.557 ms and users would never notice. For a replica in another region that is falling behind, "eventual" could be 5 minutes β the same name, but one system is usable and the other is considered broken. When somebody says their system is eventually consistent, the next question always has to be: what is the p99 of your convergence delay, and do you measure it?
9.4 Quorums: why $R + W > N$
With $N$ replicas, write to $W$ of them and read from $R$ of them. If $R + W > N$ then by the pigeonhole principle the set of $W$ nodes just written and the set of $R$ nodes being read must share at least one node β and that shared node holds the newest version. That is the whole idea.
node sysdesign-quorum.js, 100,000 rounds per configuration, with a
deterministic RNG so they reproduce.
| N | W | R | Condition | Stale reads / 100,000 |
|---|---|---|---|---|
| 3 | 1 | 1 | 2 β€ 3 | 66,581 (66.58%) |
| 3 | 1 | 2 | 3 β€ 3 | 33,167 (33.17%) |
| 3 | 2 | 1 | 3 β€ 3 | 33,336 (33.34%) |
| 3 | 2 | 2 | 4 > 3 | 0 |
| 3 | 3 | 1 | 4 > 3 | 0 |
| 3 | 1 | 3 | 4 > 3 | 0 |
| 5 | 2 | 2 | 4 β€ 5 | 30,111 (30.11%) |
| 5 | 3 | 3 | 6 > 5 | 0 |
What read-repair does β and does not do
Read-repair is this mechanism: when a read finds that some node in the $R$ set holds an old version, it writes the new version across to that node there and then. The interesting part is that it does not reduce the stale-read rate of that very read β it makes the divergence converge.
Write once to $W=1$ node, then read 40 times in a row with $R=2$, $N=3$ (20,000 trials):
| Read-repair | Read 1 | 2 | 5 | 10 | 20 | 40 |
|---|---|---|---|---|---|---|
| Off | 33.7% | 33.1% | 33.2% | 33.4% | 33.0% | 33.4% |
| On | 33.7% | 11.2% | 0.3% | 0.0% | 0.0% | 0.0% |
The two rows start out identical β 33.7% β and that is the main point: read-repair does nothing for the first read. But without it, the rate stays at 33% forever. That is the concrete meaning of an "eventual consistency" system that is never eventual: if no mechanism propagates the new version to lagging nodes, they stay stale permanently.
This is exactly the same experimental error as the jitter measurement in Lesson 5 section 5.3: the configuration never gave the mechanism a chance to work, and then the mechanism was declared useless. The practical consequence: $R = 1$ silently disables read-repair β if you are running $W=3, R=1$ for fast reads then you do have consistency, but you have no self-healing.
A partially failed write: the write reaches 1 of the 2 nodes it needed and then errors. It did not succeed, but it was not rolled back either β some later reads will see it, some will not.
Two concurrent writes: both reach a quorum, each on a different set of nodes. Now there has to be a rule deciding who wins β and if that rule is a timestamp, see section 9.5.
A replaced node: a new, empty node joins. It counts towards the quorum but holds no data, so it "agrees" without holding any version at all.
A quorum gives you overlap. Linearizability needs a global order on top of that β and that has to come from a consensus protocol (Raft, Paxos), not from an inequality.
9.5 Linearizability, serializability, and how LWW loses data
These two words get used interchangeably but describe two different things, and knowing the difference helps you ask the right question when reading a database's documentation:
| Linearizability | Serializability | |
|---|---|---|
| Talks about | A single operation on a single object | A transaction of several operations on several objects |
| Guarantees | Every operation appears to happen at a single instant, in real-time order | The result is equivalent to running the transactions one after another in some order |
| Has a real-time constraint? | Yes β if A finished before B started, B must see A | No β the equivalent order may differ from real-time order |
| Belongs to | Distributed systems, consensus | Transaction isolation levels in a database |
The practical consequence: a database can be serializable without being linearizable (the transactions are correct but you are reading from a stale replica), and vice versa. When documentation says "we support serializable", that is a statement about transactions, not a promise that your reads will see the freshest data.
Last-write-wins: losing data without a sound
When two writes arrive together, there has to be a rule for who wins. The simplest rule β and the most common β is last-write-wins: the version with the larger timestamp wins. The problem: "later" is decided by a wall clock, and two machines' clocks never agree exactly.
The scenario: client A writes at $t=1000$, client B writes 50 ms later at $t=1050$. But B's clock is skewed. Run for real:
| B's clock skew | A's stamp | B's stamp | LWW picks | Should have picked | Result |
|---|---|---|---|---|---|
| 0 ms | 1000 | 1050 | B | B | correct |
| β20 ms | 1000 | 1030 | B | B | correct |
| β50 ms | 1000 | 1000 | A | B | B's write is LOST |
| β80 ms | 1000 | 970 | A | B | B's write is LOST |
| β200 ms | 1000 | 850 | A | B | B's write is LOST |
The threshold is exactly the time gap between the two writes: a clock skew of just 50 ms β precisely the gap between them β is enough for the later write to be dropped. And it is dropped silently: both clients receive a success response, there is no error, and no log records that a value was overwritten by an older one.
function lastWriteWins({ clockSkewMs }) {
// The REAL moment (by an imaginary perfect clock), in ms.
const realTimeA = 1000;
const realTimeB = 1050; // B writes exactly 50 ms AFTER A
// But each client stamps using ITS OWN clock.
const stampA = realTimeA + 0;
const stampB = realTimeB + clockSkewMs; // B's clock is skewed
const writes = [
{ client: 'A', value: 'balance = 100', realTime: realTimeA, stamp: stampA },
{ client: 'B', value: 'balance = 150', realTime: realTimeB, stamp: stampB },
];
// LWW: the version with the HIGHEST timestamp wins.
const winner = writes.reduce((a, b) => (b.stamp > a.stamp ? b : a));
// What causality actually requires: the write that happened LATER in real time wins.
const shouldWin = writes.reduce((a, b) => (b.realTime > a.realTime ? b : a));
return {
clockSkewMs,
stampA,
stampB,
lwwWinner: winner.client,
correctWinner: shouldWin.client,
lostWrite: winner.client !== shouldWin.client,
lostValue: winner.client !== shouldWin.client ? shouldWin.value : null,
};
}
updated_at column used to decide which
version is newer when syncing; UPDATE ... WHERE updated_at < :now; the reconciliation
logic in a mobile app syncing offline changes; and the default of many multi-master systems.It works well for data where losing one write does not matter β presence status, approximate counters, caches. It is unusable for money, stock levels, or anything where a dropped write is a business incident.
Three alternatives: vector clocks (they detect the real conflict instead of hiding it, but they make the app reconcile); CRDTs (design the data structure so that every merge order produces the same result β good for sets and counters); or consensus for the operations that genuinely need one single order. And the cheapest, most overlooked route: make the operation commutative β
SET balance = 150 loses data when merged, while
balance = balance + 50 does not.
There is one more thing the table above implies: everything here depends on how far the clocks are skewed β a quantity you do not control and usually do not measure. Lesson 10 continues exactly along that line: a distributed lock also rests on an assumption about time, and when that assumption is wrong two workers both believe they are holding the lock β while Redis behaves perfectly correctly throughout.
Reproduce the measurements yourself
# Sections 9.4 and 9.5 β no Docker needed, runs in a few seconds
cd blog/sysdesign
node sysdesign-quorum.js
# Section 9.1 β a REAL partition between the app and the replica
cd sysdesign-lab
docker compose --profile replica up -d
NET=sysdesign-lab_default
curl -s "http://localhost:3001/rww?id=7" # normal
docker network disconnect $NET sysdesign-lab-postgres-replica-1 # CUT THE NETWORK
time curl -s -m 30 "http://localhost:3001/rww?id=7" # hangs 30s β NO timeout in the app
curl -s -m 30 "http://localhost:3001/rww?id=7" # ENOTFOUND in ~40ms
docker compose exec -T postgres psql -U lab -d lab -tAc \
"UPDATE profiles SET version=version+1 WHERE id=7 RETURNING version;" # WRITE still OK
# --alias is MANDATORY: without it DNS never comes back and the lab stays broken
docker network connect --alias postgres-replica $NET sysdesign-lab-postgres-replica-1
curl -s "http://localhost:3001/rww?id=7"
In summary
P is not a choice β the network will be cut. The choice between C and A only appears during a partition, and it is a choice per operation, not for the whole system. The part you meet daily is the second half of PACELC: when the network is perfectly healthy, every read path still has to choose between latency and freshness β and if you do not choose, latency has been chosen for you.
$R + W > N$ guarantees the read set and write set overlap, measured at exactly 0 stale reads across 100,000 rounds; but $R + W = N$ still gives 33%, so the inequality has to be strict. A quorum gives you overlap, not linearizability β that needs a consensus protocol. And read-repair does not improve the first read; it is what turns a 33% that stays put forever into 33% β 11.2% β 0.3% β 0%.
The most memorable finding of this lesson came from the lab: a partition does not show up as an error but as silence β the first request hung for 30 seconds, no error, no connection close. Which means that if you set no timeout you have chosen A by accident, and that is the worst of the three options: neither consistent nor available.
π References
- Gilbert & Lynch (2002) β the formal proof of the CAP theorem, turning Brewer's conjecture into a proven theorem
- Martin Kleppmann (2015) β A Critique of the CAP Theorem: a rigorous analysis of why the "pick two of three" reading is wrong, exactly the argument in section 9.1
- Daniel Abadi (2012) β the paper introducing PACELC, the source of section 9.2
- Martin Kleppmann β Please stop calling databases CP or AP: why labelling a whole system CP or AP is meaningless, the point made in the pitfall in 9.1
- Jepsen β Consistency Models: the full map of the spectrum in section 9.3, including the levels this lesson does not mention
- Ongaro & Ousterhout (2014) β Raft: the consensus protocol providing the global order a quorum does not give you (the last pitfall in section 9.4)
- crdt.tech β an overview of CRDTs, one of the three alternatives to last-write-wins in section 9.5
- Leslie Lamport (1978) β Time, Clocks, and the Ordering of Events: why wall clocks cannot be used to order events, the root of the LWW failure in section 9.5
Download the lab source
The deterministic simulator that produced every number in sections 9.4 and 9.5 β quorums, read-repair
and last-write-wins. No dependencies, runs straight away with node, no Docker needed:
Comments