Engineering Atlas › System Design Fundamentals

Why distributed systems are hard

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Partial failure is the new normal

In one process, a function call either returns or the whole program dies with it. Across a network, one part can fail while the rest carries on, and the failed part may not even know it failed. This is the partial failure problem shape, and it shows up far from databases: an AI agent calls a tool that sends an email, the HTTP request times out after 30 seconds, and the agent has no idea whether the email went out. Retrying might send it twice. Not retrying might send it never.

A request and its reply cross the network twice and change state on two machines. Amazon breaks one round trip into eight steps that can each fail on their own: posting, delivering and validating the request, updating server state, then posting, delivering and validating the reply, and updating client state. When the reply times out, the outcome is simply unknown: the work may or may not have happened (AWS Builders' Library). From the client's side, most failures look identical: silence.

The eight fallacies

At Sun, L Peter Deutsch listed seven assumptions in 1994, and James Gosling added the eighth around 1997 (Wikipedia). They are the assumptions newcomers make and later regret: the network is reliable, latency is zero, bandwidth is infinite, the network is secure, topology does not change, there is one administrator, transport cost is zero, and the network is homogeneous. Each maps to a class of outage. "Topology does not change" is the DNS record cached for an hour after failover. "Latency is zero" is the page that makes 200 sequential calls to a service 1 ms away.

What the network actually does

Packets are dropped by full switch buffers, held for seconds in queues, and delivered out of order when routes change. This is not rare: a Microsoft study of its own data centres, summarised by Kingsbury and Bailis, counted an average of 5.2 device failures and 40.8 link failures per day (aphyr.com). TCP hides this inside one connection, but not across connections. When a client reconnects and resends, the server sees a duplicate. When two requests travel on two connections, the second can land first. At-least-once queues make duplicates a normal case (see the delivery semantics lesson). Partitions are also often asymmetric: A can reach B but B cannot reach A, which defeats any design that assumes "if I can hear you, you can hear me."

Time is not shared

Every machine has its own quartz oscillator. Most computer clocks are accurate to within about 50 parts per million, and 1 ppm is 86 ms a day, so two unsynchronised servers can drift seconds apart within a day (Kleppmann's Cambridge notes). NTP corrects drift but can step the clock backwards. So "last write wins by timestamp" can silently discard the newer write, and a lease that "expires at 10:00:05" means different instants on different machines. The clock synchronization and logical clocks lessons cover the alternatives.

Processes pause

A stop-the-world GC, a VM live migration, a page fault on swapped memory or a stalled fsync all stop a thread without telling it. When it resumes, its view of the world is stale and it does not know. The classic failure: a node holds a lease, pauses for 15 seconds, the lease expires, another node takes over, and the first wakes and writes as if it were still leader. That is why the split-brain lesson insists on fencing tokens.

Truth by majority

Because no node can trust its own view, distributed systems define truth as what a quorum agreed to. A node that thinks it is leader but cannot reach a majority must step down. This is why consensus exists (see the consensus algorithms lesson), and why it is expensive.

Down the stack

Layer 1The contract

What the platform actually promises is weaker than most code assumes. A TCP connection promises in-order, deduplicated bytes only for its own lifetime; once it breaks, neither side knows how many bytes the other processed. An RPC with a timeout promises one of three outcomes: success, a definite error, or "unknown." Formally, most production systems are modelled as partially synchronous with crash-recovery nodes: messages are eventually delivered and nodes eventually respond within some bound, but you never know when that bound starts holding. The Fischer, Lynch and Paterson (FLP) result of 1985 (paper) shows that in a fully asynchronous model, no deterministic algorithm can guarantee consensus if even one process may crash, which is why every real protocol leans on timeouts.

Layer 2The mechanism

Each defensive mechanism attacks one failure. Timeouts turn silence into a decision. Retries with backoff and jitter recover from transient loss. Idempotency keys (Stripe's Idempotency-Key header, Kafka's producer id plus sequence number) make retries safe. Stripe stores the status and body of the first response for a key, replays it on retries, rejects a reuse with different parameters, and may prune keys after 24 hours, so a retry window longer than that is no longer protected (Stripe docs). Per-sender sequence numbers let a receiver drop duplicates and restore order: TCP uses 32-bit byte sequence numbers, Kafka partition offsets, Raft log indexes. Quorums make decisions survive any minority failure. Fencing tokens (ZooKeeper's zxid, etcd's revision, a Raft term) let storage reject stale leaders. Monotonic clocks (CLOCK_MONOTONIC on Linux, time.monotonic() in Python, Go's monotonic reading inside time.Time) measure durations without being thrown off by NTP steps.

Layer 3The machine

A dropped packet costs time measured in hundreds of milliseconds, not microseconds. Linux's minimum retransmission timeout is 200 ms, and RFC 6298 sets the initial RTO to 1 second (RFC 6298), so one lost SYN adds a full second to a connect. A 2023 kernel change made the first four SYN retries linear (1 s apart, as macOS does) before exponential backoff starts, and with the default tcp_syn_retries=6 the kernel documents the final timeout for a connect to a black-holed address as about 131 seconds; older write-ups say 127. With tcp_retries2=15, an established connection whose peer vanished takes at least 924.6 seconds, roughly 15 minutes, to error out (Linux ip-sysctl docs). So application timeouts must be far shorter than the kernel's. Within a data centre, round trips are 0.1 to 0.5 ms at the median, but tails of 10 to 100 ms come from queueing in shallow switch buffers, incast when many servers reply at once, and busy CPUs. A full GC on a 30 GB JVM heap can take seconds, long enough to miss many heartbeats.

Why it's built this way

  1. A single machine has a hard ceiling on CPU, memory, disk and failure domain, so large systems must span many machines.
  2. Machines communicate only by messages over shared, lossy links, so every interaction can fail in the middle and the caller cannot see where.
  3. Waiting forever is unacceptable, so callers use timeouts, which means they sometimes declare a live peer dead.
  4. Wrongly declaring a peer dead creates duplicates and two leaders, so every action that matters needs idempotency, sequence numbers or fencing to make the mistake harmless.
  5. Making mistakes harmless is cheaper than avoiding them, which is why the toolkit is built around tolerating ambiguity rather than eliminating it.

Diagram

sequenceDiagram
  participant C as Client
  participant N as Network
  participant S as Server
  C->>N: charge(key=k1, 50)
  N->>S: deliver
  S->>S: apply charge, store k1
  S-->>N: 200 OK
  N--xC: reply lost
  C->>C: timeout after 2s: unknown
  C->>N: retry charge(key=k1, 50)
  N->>S: deliver
  S->>S: k1 seen, return saved result
  S-->>C: 200 OK (no second charge)

Worked example

A checkout service calls a payment provider at 400 payments per second at peak, and about 0.05% of calls hit the 3-second client timeout because of a lost reply or a slow provider. That is 0.2 ambiguous payments per second, or about 17,000 per day. If the provider had actually processed 70% of them, a naive retry causes about 12,000 double charges per day. With an idempotency key generated once per checkout attempt and sent on every retry, the provider returns the stored result and the duplicate count drops to zero.

Now add ordering. The same service publishes payment_captured and payment_refunded events to a queue with at-least-once delivery. A consumer that applies them as they arrive can see the refund before the capture after a redelivery, and mark an order "captured" after it was refunded. Attaching a per-order sequence number and having the consumer keep next_expected per order fixes both problems: a duplicate (seq below next_expected) is dropped, and an early message is buffered until the gap fills. The cost is a head-of-line wait when one message is lost, so the consumer needs a way to ask for the missing one, which is what the practice task builds. Ordering by producer wall-clock timestamps instead would fail: with 300 ms of skew between two producers, a refund issued 100 ms after a capture appears to come first.

In the wild

Trade-offs

ApproachStrengthsWeaknessesChoose when
Retry with idempotency keySimple, recovers from loss and timeoutsServer must store keys and results; key scope must be rightAny state-changing call over a network
Per-sender sequence numbersExact dedup and ordering per streamHead-of-line blocking on a gap; needs retransmit pathStreams of events per entity
Wall-clock timestampsNo coordinationWrong under drift and steps; silently loses writesLogging and display only
Quorum decisionsSurvive any minority failureExtra round trips; minority side stops servingLeadership, metadata, money
Deterministic simulation testingFinds rare interleavings reproduciblyCode must route I/O and time through a simulatorCore storage and coordination code

Pitfalls in production

Leadership lens

Check yourself

  1. A client sends a write, gets no reply after 5 seconds, and the server log later shows the write was applied. Was the network "down"?

    Show answer

    Not necessarily. The reply may have been lost or delayed, or the server may have been paused. The client only knows it did not hear back in time, which is why timeouts must be treated as unknown outcomes.

  2. Why does TCP's in-order, deduplicated delivery not remove the need for application-level sequence numbers?

    Show answer

    TCP's guarantees end with the connection. A reconnect and resend, a second connection, or a queue redelivery all create duplicates and reorderings that TCP never sees.

  3. A lease holder checks now < expires_at and then writes. What can still go wrong?

    Show answer

    The process can pause between the check and the write, the lease can expire and be granted to someone else, and the stale write then lands. Storage must check a fencing token to reject it.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →