The Distributed Systems Lab
Every interactive in the domain in one place: spacetime diagrams, quorum overlap, replicated logs, linearizability checks, partition simulators, delivery semantics, backpressure and recovery. Pick an area and the widgets for it render below — each one is the same component the lesson uses.
Interactives
172
Areas
23
Fundamentals
What a machine boundary actually changes · 9 interactives
The four things a machine boundary takes away
What Actually Makes a System Distributed →four-consequences-explorerThe four things a boundary takes away
Distribution adds no guarantee. It removes four, and everything else in this domain is a consequence. Move the boundary and watch which ones go.
Where is the boundary?
guarantees lost
4 of 4
one round trip
hundreds of microseconds
failure detection
unreliable
in this domain?
yes
No shared memory
State exists as copies with ages. Two nodes never read the same bytes.
→ Replication — and a decision about what "the value" even means.
No global clock
Two nodes cannot agree what "now" is, so a timestamp comparison is not an ordering.
→ Ordering rebuilt from causality: messages, not clocks.
No instant communication
Every fact about another node describes its past.
→ Every read of remote state becomes a read of remote history.
No perfect failure detector
A crashed node and a slow node produce identical evidence: nothing.
→ Suspect, agree, fence — you decide a node is down and survive being wrong.
Two machines, one rack. The first genuinely distributed row. Clocks agree approximately; silence has five causes. Both clauses of the test hold: a component your correctness depends on fails independently of you, and the only way you learn its state is a message that can be lost, delayed, reordered or duplicated. Two nodes or two thousand — the reasoning is the same.
typicalThe communication costs are order-of-magnitude figures for ordinary deployments, not measurements of yours. Which guarantees are lost at each boundary is not a measurement at all — it follows from where the boundary is.
A local call has two outcomes; a remote call has three
A Remote Call Is Not a Function Call →remote-call-outcome-explorerA local call has two outcomes. A remote call has three.
Same call site, same catch block. Choose what happened on the wire and read what the caller is entitled to conclude.
What happened?
If this were a local call
try {
const receipt = charge(order) // returned => it happened, once
markPaid(order, receipt)
} catch (e) {
markFailed(order) // threw => it did not happen
}Two outcomes, and the language guarantees the space is exhaustive. Your error handling is complete because it cannot be incomplete.
What actually happened, and what you saw
executions at the callee
1
effects durable?
yes — committed
caller observed
nothing, until the deadline expired
outcome class
no response — the third outcome
The work is durable. Nobody told the caller. The catch block runs and calls markFailed(order) — which is flatly wrong: the charge is durable and the order is now marked failed.
Caller and callee under: Committed, then crashed before replyingsimplified
delivereddelayed (dashed, long)duplicated (×2)dropped — stops short, never arriveswritecrashdecide
The interesting question is never how fast a remote call is. It is what may I conclude from what I observed. Three of these eight worlds produce identical evidence at the caller, and two of those three left durable effects behind. What crosses the boundary is data you deliberately sent — an id, a deadline, an idempotency key. Everything a local call gets from the runtime for free must be carried explicitly.
protocolThe outcome space is not a property of any framework: it follows from the callee being a separate process reached by messages. The stage names and the timings in the diagram are a simplification of a real RPC path.
Loss, delay, reordering, duplication, partition — and which layer removes which
The Network Changes Everything →network-behaviour-simulatorFive behaviours, and which layer removes which
Loss, delay, reordering, duplication, partition. TCP removes two of them inside one connection, converts one into another, and cannot see the fifth.
never arrived
0
arrived late
3
arrived twice
0
out of order at the app
yes
Eight requests on one connection. Shape, not colour, tells you the fate.simplified
delivereddelayed (dashed, long)duplicated (×2)dropped — stops short, never arrives
| Behaviour | TCP, within one connection | Still visible here? | What you must do |
|---|---|---|---|
| Loss | Retransmits, then gives up | no | Application retry with a deadline |
| Delay | Made worse by retransmission | yes | Impose a deadline; treat expiry as unknown, not failure |
| Reordering | Removed within one connection | yes | Carry a sequence number or version if order matters |
| Duplication | Removed at byte level | no | Idempotent handling keyed by a caller-chosen id |
| Partition | Invisible | no | Decide what each side does when it cannot reach the other |
TCP removes reordering and duplication at byte level and converts loss into delay. Your own retries reintroduce duplication at the application level, and a connection reset discards in-flight data while telling you nothing about how much the peer consumed.
simplifiedFates are drawn from a seeded generator, so the picture is reproducible rather than measured. Real paths lose in bursts, reorder on route changes, and retransmit on timers this model does not have. The five behaviours and which layer removes which are not simplifications.
No shared variable — only copies with ages
No Shared Memory: Every Node Sees a Copy →copy-staleness-explorerThere is no shared variable — only copies with ages
The owner sets stock = 0. Every other node keeps serving a value that was true at the owner, in the past. Scrub through and read how far behind each copy is.
ownerstock = 0owns this value
replica-bstock = 11 version behind
replica-cstock = 11 version behind
step
4
messages in flight
4
converged
at step 11
dropped by the partition
0
Every copy carries the owner’s latest version from step 11 onward. Until then, a reader on a replica sold stock that no longer existed — and nothing errored, because from that replica’s point of view its value was correct. A fresher read narrows the window; it does not close it. Any read followed by a separate write has a gap. The fix for a stale read that guards a write is a conditional write — send the version you believed and let the owner reject you — not a faster refresh.
// reads-then-writes on a copy: the gap is structural
const stock = replica.get("sku-9") // true at the owner, some time ago
if (stock > 0) owner.decrement("sku-9") // <- the owner may already be at 0
// the same operation with no gap: the check happens where the state lives
owner.decrementIf("sku-9", { expectedVersion: stock.version })
// -> either it applies, or it fails and tells you your copy was oldsimplifiedPropagation is modelled in discrete steps by the domain’s anti-entropy model: a node that learns a newer version pushes it to every neighbour it can still reach. Real replication batches, compresses and reorders. That a copy is a dated snapshot of the owner’s past is not a simplification.
Timestamp order versus the order that actually happened
There Is No Global Clock →clock-skew-ordering-labTimestamp order versus the order that actually happened
e1 → e2 by a message, so that ordering is a fact. e3 is concurrent with both. Move the per-host offsets and watch the timestamps disagree with all of it.
by timestampe2 (90)e1 (100)e3 (200)
by causalitye1 → e2e3 ∥ e1e3 ∥ e2
The timestamps say e2 (90) is not later than e1 (100) — yet e2 is the receipt of a message e1 sent. This ordering is not merely unreliable, it is provably wrong, and no amount of NTP removes it: it only requires B’s clock to be behind A’s by more than the transit time.
Last-write-wins on key x
e1 wrote
"alice" @ 100
e3 wrote
"carol" @ 200
LWW keeps carol, because C wrote the higher number. It agrees with real time at these offsets — but nothing in the system checked that, and the two writes are concurrent, so there is no fact of the matter about which "should" win. Discarding one of them is a decision LWW makes for you, silently.
Three hosts, three oscillators. The label on each event is what that host wrote down.assumption
delivereddelayed (dashed, long)duplicated (×2)dropped — stops short, never arriveswriteread
A node knows its own clock reading. It cannot measure its offset against a peer — only against a time server, over a round trip whose two directions it assumes took equal time. Comparing two hosts’ timestamps inherits the sum of both errors. Causality is the ordering that needs no assumption: it is established by messages, it is partial, and the events it refuses to order are genuinely unordered.
assumptionOffsets here are held fixed; in reality they drift and are stepped, so the same pair of events can compare differently an hour apart. That a cross-host timestamp comparison asserts skew is smaller than the interval between the events is exact, and is the assumption nobody writes down.
What problem are you actually solving?
Why Distribute At All →why-distribute-decision-treeWhat problem are you actually solving?
Four honest reasons to put this on more than one machine. Each buys one conditional property, and the condition is where the design work lives.
Which reason applies?
Scale what, exactly?
buys you
Work beyond what one machine can do
only if
the work partitions with few cross-partition operations
costs, immediately
Partition keys, rebalancing, hot shards
would a bigger machine do?
Yes — a bigger machine buys time and, more usefully, the data to choose a partition key from.
smallest thing that works
Cache, then read replicas
difficulty
cheap and reversible
a bigger machine probably covers this
The cheapest rung on the ladder. Most "we need to scale" is this one. State the goal as a number with a horizon before choosing a topology: 3× read in 12 months. A requirement in that form can be evaluated; "we need to scale" cannot, and it is how teams end up with a sharded system that only ever needed read replicas.
typicalThe recommended step at each leaf is common practice, not a measurement. Every one of them assumes you have stated the requirement as a number first — a target with a time horizon — because otherwise there is nothing to evaluate against.
Costs certain and immediate; benefits conditional and deferred
When Not to Distribute →distribution-cost-ledgerCosts certain and immediate; benefits conditional and deferred
One user action, crossing a boundary a few times. The latency and the availability are arithmetic; the asymmetry in the ledger is the argument.
user-visible median
48 ms
one slow hop dominates
p99 of one call = 180 ms
operation availability
99.6%
unavailable minutes / month
173
single call p99180 ms
slowest of 4 parallel calls, p99314.5 ms · the tail of the fan-out is not the tail of one call
| When it is paid | Certainty | |
|---|---|---|
| Partial failure between componentsprotocol | Day one | Certain |
| Ambiguous call outcomesprotocol | Day one | Certain |
| Cross-process debuggingtypical | First incident | Certain |
| Deployment and version coordinationtypical | Every release | Certain |
| Capacity beyond one machineassumption | When you exceed one machine | Conditional on partitionability |
| Survives a node failureassumption | At the failure | Conditional on independence |
| Independent team velocityassumption | If boundaries match change patterns | Conditional, and often false |
Each service here is individually 99.90% available and individually fast. The operation is 99.6% available, because availability is a property of the operation across all of its dependencies rather than of any one service. Every crossing adds a median 12 ms that a local call did not cost, and a chance of the third outcome that a local call could not produce. A module boundary inside one deployable buys most of the organisational benefit at none of this price, and it is the reversible option — moving a module boundary is a refactor; moving a service boundary is a data migration plus a contract change plus a coordinated deploy.
simplifiedAvailability multiplies only if the dependencies fail independently — they usually do not, which is a separate lesson. The fan-out tail is the domain’s log-normal latency model fitted through your p50 and p99, not a measurement of your system. The sequential figure is a sum of medians: a real chain’s tail is worse than its median suggests and better than hops × p99.
Where the cut goes, and what the cut costs
Where the Boundary Goes →boundary-placement-labWhere the cut goes, and what the cut costs
Move components across the boundary. An invariant that spans the cut is a rule you have chosen to enforce with a protocol, forever.
Preset cuts
invariants spanning the cut
0
crossings per order
1
boundary
two failure domains
when the link is down
one side degrades
inside one sideorder.total equals the sum of its itemsorders:A order_items:A
inside one sideno order ships without an authorised paymentorders:A payments:A
inside one sidestock on hand never goes negativeinventory:A
inside one sideevery captured payment appears exactly once in the ledgerpayments:A ledger:A
The cut, drawn as what it is: a failure surface. Every line crossing it is a message that can be lost, delayed, reordered or duplicated.simplified
ok
- notifications — other side of the cut
No invariant spans this cut, so each side can enforce its own rules locally. One order crosses it rarely, which is what a well-placed boundary looks like. The durable form of a boundary is single-writer ownership: separate storage is a consequence of that rule, not the rule itself.
simplifiedA four-invariant model of one ordering system. The test it applies — does any invariant span the cut, how many crossings does one user action make, what still works when the link is down — is the real one, and it is the one a service diagram cannot answer.
Name the invariant, then price its scope
Name the Invariant Before You Choose the Protocol →invariant-scope-explorerName the invariant, then price its scope
An invariant is the guarantee. A quorum, a lock, a leader, a transaction are mechanisms whose only job is to keep some named predicate true.
Pick an invariant
What scope are you assuming it has?
enforced by
Distributed transaction, or a single-partition redesign
cost
A round trip plus coupled availability
what breaks it
A participant failing between prepare and commit
true scope of this invariant
partitions
At "partitions" scope the predicate can be evaluated by no single owner, so the price is paid in round trips and in availability coupled across participants. Nothing here is checking the predicate. Turn the check on: this failure class produces no errors, so an unmeasured invariant is an unenforced one.
Weakening is a design tool, not a defeat. Localise: make the seat its own partition and the invariant becomes single-owner. The four legitimate moves are localise, bound, detect-and-compensate, and choose operations that commute. The one that is not available is leaving a required invariant unenforced and calling it eventual consistency — convergence preserves a violation faithfully.
assumptionThe cost of each rung assumes the mechanism named beside it and an otherwise healthy system. Every row also assumes the predicate is actually checked against real data — an invariant that is not continuously verified is not enforced, because this failure class is silent.