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.

ℹ️ Where the numbers in this lesson come from
Sections 9.4 and 9.5 run on 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.

⚠️ Pitfall: using CAP as an excuse
"We're an AP system so we don't need strong consistency" is the most common abuse of CAP. It is wrong in two places.

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:

cause_real_partition.sh
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
πŸ”¬ The first row is the most important one
A partition does not show up as an error. It shows up as silence. An open TCP connection to a node that has vanished reports nothing at all β€” no error, no connection close β€” so the caller waits until some timeout cuts it off. There is no timeout at the app layer here, so the thing that finally cuts it is 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.
The partition has already happened β€” only now is there a choice, and there are only two PARTITION β€” the network is split n1 n2 n3 βœ• n1,n2 can see each other Β· n3 is isolated CHOOSE C β€” stay correct n3 REFUSES every read and write (it cannot know whether it is still fresh) βœ“ Never returns wrong data βœ— n3's clients get errors β€” availability lost Examples: account balances, ticket booking, stock levels CHOOSE A β€” stay up n3 STILL accepts writes, and so do n1+n2 β‡’ two parallel versions of the data βœ“ Nobody gets an error βœ— The data DIVERGES β€” it must be reconciled later Examples: likes, shopping carts, notifications And what did the lab measure? A real partition between the app and the replica: First request: HANGS FOR 30 SECONDS no error, no connection close Later requests: error in 29–40 ms ENOTFOUND β€” fast, explicit failure Write to the primary: OK, 135 ms PARTIALLY available β€” CAP has no name for this state The lesson: No timeout = you have chosen A BY ACCIDENT. And that is the worst choice: neither consistent nor available. The C-or-A choice is made PER OPERATION, not for the whole system β€” and if you do not choose, the defaults will choose for you.
What CAP does not say: there is a third option, worse than either β€” deciding nothing and letting the default timeouts decide for you.

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
πŸ’‘ Why PACELC is more useful than CAP in day-to-day work
CAP helps you understand a situation you hope never to meet. PACELC gives a name to a decision you have already made dozens of times in this series without calling it a consistency decision.

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.

Each step up eliminates one anomaly the user can actually FEEL LEVEL ANOMALY ELIMINATED PRICE EVENTUAL weakest (eliminates nothing) β€” reads can go backwards, jump around, or stay stale forever how long is "eventual"? 50 ms and 5 minutes are two completely different systems cheapest MONOTONIC READS Data never goes BACKWARDS: having seen version 6, you never see 5 again Fixes the "hit F5 twice, get two answers" bug from Lesson 7 section 7.3 pin each session to 1 replica READ-YOUR-WRITES You ALWAYS read back what you yourself just wrote Fixes the avatar bug: measured 87.21% β†’ 0.00% (Lesson 7) βˆ’4.6% throughput CAUSAL Causes always arrive before their effects No more seeing the reply before the question requires tracking causal relationships LINEARIZABLE strongest The system behaves as if there were only ONE copy of the data Every operation has a global order matching real time most expensive The three middle levels are almost always the right answer: strong enough that users notice nothing odd, cheap enough not to cost availability.
The "price" column comes from the real measurements in Lesson 7. The point to remember: you do not need linearizable for everything β€” you need strong enough that the matching anomaly never reaches your users.
⚠️ Pitfall: saying "eventual consistency" without saying how long eventual is
"Eventual consistency" only promises that if writes stop, all replicas will eventually agree. It promises nothing about how long β€” and that is the whole difference in experience.

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.

N = 3 Β· a shaded cell = a node in the write set (W) or the read set (R) W=1 Β· R=1 β†’ R+W=2 ≀ 3 write: read: NO overlap β†’ measured 66.58% stale reads W=2 Β· R=1 β†’ R+W=3 ≀ 3 write: read: Equal to N is NOT ENOUGH β†’ 33.34% stale reads W=2 Β· R=2 β†’ R+W=4 > 3 write: read: overlap Always overlaps β†’ measured 0 stale reads The inequality must be STRICT R + W = N still gives 33% stale reads (measured, 100,000 rounds). Only R + W > N guarantees it, and then the measurement is EXACTLY 0 β€” not "close to 0". Choosing W and R is choosing fast writes or fast reads W=3,R=1: slowest writes, fastest reads Β· W=1,R=3: the reverse Β· W=2,R=2: balanced β€” all three give 0 stale reads.
Figures from 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.

⚑ Read-repair needs $R \ge 2$ to have any effect
My first experiment used $R = 1$, and read-repair made no difference at all β€” both rows sat at 66.6%. The reason is simple once you see it: a read set of one node has nothing to compare against and nothing to repair. If that node is stale, the read sees the stale version and writes back that same stale version.

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.
⚠️ Pitfall: assuming a quorum automatically gives linearizability
The simulation above gives exactly 0 stale reads when $R+W>N$, but it models one writer and monotonically increasing versions. Reality is more complicated, and $R+W>N$ is not sufficient for linearizability in three cases:

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.

sysdesign-quorum.js β€” the lastWriteWins function
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,
  };
}
⚠️ Pitfall: LWW is the default in more places than you think
LWW shows up in many places under other names: an 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

reproduce_measurements.sh
# 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

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:

Download sysdesign-quorum.js

Related lessons in this series

Lesson 8: Sharding & Consistent Hashing Lesson 10: Distributed Locks Back to the System Design roadmap

Comments