#Why distributed systems are hard
AddedA distributed system is any system where the work is split across machines that talk over a network: a web server and its database, three replicas of a key-value store, twenty microservices. The moment two machines cooperate, you inherit three problems a single process never has. Partial failure: one part can fail while the rest keeps running, and the rest may not even know. Unreliable networks: messages can be lost, delayed, duplicated or reordered, and from the sender's side all of those look the same. Unreliable clocks: each machine has its own clock, they drift apart, and they can jump backwards.
On one machine, a function call either returns or throws. Across a network there is a third outcome: you don't know. You sent "charge the card" and heard nothing back within your timeout. Maybe the request never arrived. Maybe the server charged the card and the reply was lost. Maybe the server is slow and the reply is still on its way. Your code has to do something sensible without being able to tell which.
A mental model that sticks: you're coordinating with friends by post, with no phone. Letters can get lost or arrive weeks late, a friend might have moved away without telling you, and every friend's watch shows a slightly different time. "Did Sam get my letter?" can only be answered by another letter, which can also be lost. Every technique in this chapter (timeouts, retries, idempotency keys, quorums, logical clocks, consensus) is a way to get useful work done by post.
The model below charges 10,000 cards over a network that loses 5% of requests, loses 5% of replies, and is too slow 5% of the time. The client does what most HTTP clients do: on a timeout it retries, up to three attempts in total. It runs twice: once against a server that just charges, and once against a server that remembers an idempotency key (the payment id) and treats a repeat as a no-op. The randomness is a seeded PRNG (mulberry32), so it prints the same numbers every run.
// A client charges a card over a lossy network and retries on timeout. Seeded, so every run is identical.
function mulberry32(seed) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = mulberry32(42);
// One attempt: what actually happened, and what the client saw.
function attempt(server, paymentId, useKey) {
const r = rand();
if (r < 0.05) return "timeout"; // request lost: server never saw it
server.charge(paymentId, useKey); // the server did the work...
if (r < 0.10) return "timeout"; // ...but the reply was lost
if (r < 0.15) return "timeout"; // ...or arrived after the client gave up
return "ok";
}
function run(useKey) {
const charges = new Map(); // paymentId -> times the card was charged
const seenKeys = new Set();
const server = {
charge(id, useKey) {
if (useKey && seenKeys.has(id)) return; // idempotency key: repeat = no-op
seenKeys.add(id);
charges.set(id, (charges.get(id) ?? 0) + 1);
},
};
const outcome = { ok: 0, gaveUp: 0, doubleCharged: 0, chargedButToldFailed: 0 };
for (let id = 0; id < 10_000; id++) {
let result = "timeout";
for (let tries = 0; tries < 3 && result === "timeout"; tries++) result = attempt(server, id, useKey);
const n = charges.get(id) ?? 0;
if (result === "ok") outcome.ok++;
else outcome.gaveUp++;
if (n > 1) outcome.doubleCharged++;
if (result !== "ok" && n > 0) outcome.chargedButToldFailed++;
}
return outcome;
}
console.log("retry, no key :", run(false));
console.log("retry + key :", run(true));retry, no key : { ok: 9958, gaveUp: 42, doubleCharged: 1064, chargedButToldFailed: 41 }
retry + key : { ok: 9957, gaveUp: 43, doubleCharged: 0, chargedButToldFailed: 41 }- Each attempt draws one random number. Below
0.05the request is lost and the server never runscharge. Between0.05and0.15the server does runcharge, and then the reply is lost or too late. Only above0.15does the client get"ok". Three of the four outcomes look the same to the client:"timeout". - Without a key, every retry after cases 2 and 3 charges the card again: 1,064 of 10,000 customers were charged more than once. The retry that was meant to make the system reliable is what caused the damage, because the client retried work that had already happened.
gaveUp: 42are payments where all three attempts timed out, so the client reported a failure. ButchargedButToldFailed: 41: in 41 of those 42 the card was charged. "The request failed" really meant "I don't know". On a single machine you'd never confuse the two.- With the key,
server.chargechecksseenKeysfirst, so a retry of work already done changes nothing:doubleCharged: 0. The client can now retry freely, because the operation is idempotent (doing it twice has the same effect as once). - The key doesn't fix the 41 "told failed, actually charged" cases on its own. What it gives you is the right recovery: retry later with the same key (or ask "what happened to payment 123?"), and the answer comes back without a second charge. That's the shape of Idempotency and exactly-once and of payment APIs.
L. Peter Deutsch at Sun Microsystems wrote down seven false assumptions in 1994, building on earlier work by Bill Joy and Dave Lyon, and James Gosling added the eighth around 1997 (Wikipedia, checked October 2026). Every one of them still turns up in design reviews, so it helps to know what each one costs you in a design:
| Fallacy | What breaks when you believe it | What the design does instead |
|---|---|---|
| The network is reliable | calls hang, retries duplicate work | timeouts on every call, retries with backoff, idempotent operations |
| Latency is zero | chatty APIs: 50 calls × 2 ms is 100 ms | batch, cache, keep data near the code that uses it |
| Bandwidth is infinite | big payloads, full-object syncs saturate links | pagination, compression, deltas instead of snapshots |
| The network is secure | plain-text traffic inside the data centre | TLS everywhere (mTLS between services), least privilege |
| Topology doesn't change | hard-coded IPs, caches of "who is the leader" | service discovery, health checks, re-resolve on failure |
| There is one administrator | one team's config change breaks another | clear ownership, versioned contracts, staged rollouts |
| Transport cost is zero | serialisation CPU and cross-zone traffic bills | measure it; keep hot paths in one zone where possible |
| The network is homogeneous | one slow or old hop drags everything | timeouts per hop, tolerate mixed versions and protocols |
How often does the network actually fail? A Microsoft Research study of a year of network logs from tens of its data centres (Gill, Jain and Nagappan, SIGCOMM 2011) found that load balancers caused the most failures and that network redundancy was only about 40% effective at reducing the median impact of a failure. Redundancy helps, but it doesn't make the first fallacy true.
Two facts about time on a server. A wall clock (Date.now(), System.currentTimeMillis()) tells you the date and time, and NTP can move it forwards or backwards to correct drift. A monotonic clock (performance.now() and process.hrtime.bigint() in Node) only ever moves forwards, but its value means nothing across machines; it's only good for measuring durations on one machine. And different machines' wall clocks disagree: NTP usually keeps clocks within tens of milliseconds over the public internet and under a millisecond on a good LAN, but asymmetric routes and congestion can cause errors of 100 ms or more (Wikipedia's NTP article, October 2026).
// Two things wall clocks get wrong, with fake clocks so the output is exact.
// 1. Measuring a duration with the wall clock while NTP steps it back by 1.2 s.
let trueMs = 1_000_000; // real time, never jumps
let wallOffset = 0; // what NTP adds or removes
const wall = () => trueMs + wallOffset;
const monotonic = () => trueMs; // like performance.now(): only goes forward
const w0 = wall(), m0 = monotonic();
trueMs += 300; // the upstream call really takes 300 ms...
wallOffset -= 1200; // ...and meanwhile NTP corrects a fast clock
console.log("wall-clock duration :", wall() - w0, "ms");
console.log("monotonic duration :", monotonic() - m0, "ms");
// 2. Ordering writes on two servers by their own wall clocks.
const servers = {
A: { skewMs: 0 }, // accurate
B: { skewMs: -80 }, // 80 ms behind
};
const stamp = (server, realMs) => realMs + servers[server].skewMs;
const writes = [
{ value: "email=old@x.com", server: "A", realMs: 5_000 },
{ value: "email=new@x.com", server: "B", realMs: 5_030 }, // really happened 30 ms later
];
for (const w of writes) w.ts = stamp(w.server, w.realMs);
const winner = writes.reduce((a, b) => (b.ts > a.ts ? b : a)); // last-write-wins by timestamp
console.log(writes.map((w) => `${w.value} @${w.server} ts=${w.ts}`).join(" | "));
console.log("LWW keeps:", winner.value, "(the user's latest change is silently lost)");wall-clock duration : -900 ms
monotonic duration : 300 ms
email=old@x.com @A ts=5000 | email=new@x.com @B ts=4950
LWW keeps: email=old@x.com (the user's latest change is silently lost)- Part 1 measures a 300 ms call with both clocks while NTP steps the wall clock back by 1.2 seconds (it does that when it finds the clock running fast). The wall clock says the call took −900 ms. The monotonic clock says 300 ms, the truth, because nothing ever adjusts it.
- A negative duration isn't just a funny log line. On 1 January 2017 a leap second made a duration in Cloudflare's Go DNS server negative; it was passed to
rand.Int63n, which panics on a negative argument, and a small share of DNS queries failed (The Register's report, 2017). Go 1.9 later added a monotonic reading totime.Now(Go 1.9 release notes). Rule: measure durations with a monotonic clock. - Part 2 orders two writes by the timestamps the servers stamped on them. Server B's clock is 80 ms behind, so the edit made on B 30 ms after the edit on A gets timestamp
4950, which is earlier than A's5000. - Last-write-wins compares timestamps, keeps
old@x.comand throws the newer email away, with no error anywhere. Any design that orders events across machines by wall-clock time has this bug whenever the skew is bigger than the gap between writes. - That's why the rest of the chapter avoids comparing wall clocks between machines: Lamport and vector clocks order events by causality instead, consensus orders them through a leader's log, and CRDTs make the order not matter.
Process pauses: the node that doesn't know it's been asleep
A process can stop for a long time without crashing: a stop-the-world garbage collection, a VM being live-migrated, a laptop lid closing, a container being throttled. When it resumes, it carries on from the next line as if no time had passed. If it was holding a lease ("I'm the leader for 10 more seconds"), it may still believe it holds it, while the rest of the cluster has already picked someone else. Code can't check "am I still leader?" and then act, because the pause can happen between the check and the action. This is the real reason distributed locks need fencing tokens, and it's modelled in Leader election and consensus.
What to say in the interview
When you add a network hop to a design, say what happens when it fails. For each arrow on your diagram, the useful questions are: what's the timeout, is the call retried, is it safe to retry (idempotent), and what does the caller do if it never gets an answer? Naming partial failure early ("the payment service might charge the card and then time out on us, so the call carries an idempotency key") is one of the clearest signals of distributed-systems experience an interviewer looks for.
A checkout calls the payment service with a 2-second timeout and retries once. The payment service is healthy but takes 2.5 seconds for a slow card network. No idempotency key is sent. What does the customer see, and what happens to their card?
Show answer
The checkout shows an error, or a success after the retry, and the card is charged twice.
- Attempt 1 reaches the payment service, which starts the charge. At 2 seconds the checkout's timeout fires; it doesn't know the charge is in progress.
- The checkout retries. Attempt 2 is a brand-new request to the payment service, which has no way to know it's a repeat, so it starts a second charge.
- At 2.5 seconds attempt 1 completes: card charged. Its reply arrives at a client that has stopped listening.
- Attempt 2 either completes in time (the user sees "success", with two charges) or times out as well (the user sees an error, with two charges, and probably tries again).
- The fix is not a longer timeout (some call will always be slower). It's an idempotency key generated once per checkout and sent on every attempt, so the payment service returns the first result for the repeat.