Search Tech Journey

Find topics, journeys and posts

6-month learning plan70 / 130
back to blog
systemsintermediate 55m read

S070 · CAP & PACELC — the Actual Trade-Offs

The three-letter theorem everyone quotes wrong, and the five-letter one that fills in the gap. Consistency, Availability, Partition-tolerance — and what happens the 99.9% of the time your network is fine.

⚙️SystemsM08 · Distributed Systems· Session 070 of 130 90 min

🎯 Explain CAP and PACELC accurately (not the interview cliché), classify real databases against them, and pick a consistency model given a workload.

Why this session exists

"CAP theorem" is the most cargo-culted phrase in distributed systems. Almost every explanation you'll hear online — "you can only have two of three!" — is wrong. This session unpacks what Brewer actually proved, what modern practitioners (Abadi's PACELC) added, and how to use both to reason about real systems like DynamoDB, Cosmos, MongoDB, Cassandra, and Spanner.

You will be able to
  • State CAP precisely: during a partition, you must choose between Consistency and Availability.
  • Explain PACELC: even when NOT partitioned, systems trade Latency for Consistency.
  • Classify DynamoDB, Cosmos, Cassandra, MongoDB, Spanner, Postgres against CAP + PACELC.
  • Pick a consistency level (linearizable / bounded staleness / session / eventual) for a given workload.

Prerequisites

  • S042 · Transactions & ACID — you know what atomicity + isolation mean.
  • S062 · Load balancers — health checks + failover mental model.
  • S063 · Caching — TTL + staleness trade-offs at small scale.


(a) Intuition · 5 min

Two ATMs on either side of a torn phone line
🌍 Real world

You have $1000. You visit ATM-A and withdraw $600. Simultaneously, your partner visits ATM-B and withdraws $600. Both ATMs used to sync via a landline. This morning, the line is down.

Two choices for the bank: (1) refuse both withdrawals — consistent but unavailable — or (2) let both go through and reconcile later, resulting in a $200 overdraft — available but temporarily inconsistent.

There is no third option. The line is down; you cannot both stay accurate AND keep serving customers.

💻 Code world

That's CAP. When your distributed system suffers a Partition (network failure between nodes), you must choose: keep serving requests with possibly-stale data (AP), or reject requests until the partition heals (CP). You cannot have both.

The trap is quoting "you get 2 of 3". You don't get to choose Partition-tolerance — networks WILL partition. The real choice is "when it happens, which do I preserve?"

CAP, precisely — Brewer + Gilbert-Lynch

What the theorem actually says
  • Consistency — every read sees the most recent write or an error (linearizability, roughly).
  • Availability — every request receives a (non-error) response, even if some nodes are down.
  • Partition-tolerance — the system keeps working even when arbitrary messages between nodes are dropped.
  • The theorem: in the presence of a network Partition, you must sacrifice EITHER Consistency OR Availability. There's no CA option in a real distributed system because P is not optional in the real internet.

The gap CAP leaves — what happens when there's NO partition?

A quick history so the ideas make sense

  1. 2000
    Brewer proposes CAP at PODC
    Eric Brewer's keynote conjecture: consistency, availability, partition-tolerance — pick two.
  2. 2002
    Gilbert & Lynch formalize it
    MIT researchers prove CAP as a theorem, with precise definitions.
  3. 2007
    Dynamo paper · Amazon
    ‘Highly available key-value store’ — the archetype of AP. Explicitly trades consistency for uptime.
  4. 2012
    Abadi publishes PACELC
    ‘CAP is incomplete — you also trade Latency for Consistency in normal operation.’ The theorem practitioners actually use.
  5. 2013
    Spanner paper · Google
    ‘Externally-consistent distributed transactions at global scale’ — a CP system with GPS + atomic clocks (TrueTime). Existence proof that CP-with-good-latency is possible if you're willing to buy the hardware.
  6. 2017
    Cosmos DB launches with 5 consistency levels
    The industry accepts consistency is a SPECTRUM, not a switch. Cosmos exposes it directly.

(b) Visual walkthrough · 15 min

CAP — the classic diagram (and its trap)

The trap: the "CA" corner is where beginners land. In a single-node database (Postgres on one box), there's no partition and CA is trivially satisfied — but it's not distributed. Once you go multi-node, P is a given and you're always choosing CP or AP.

PACELC — the fuller picture

Reading the four combinations

PA/EL
Under Partition: Availability. Else: Latency. → Dynamo, Cassandra, Riak, Cosmos DB (eventual). Fast reads always; possibly stale.
AP/eventual
PA/EC
Under Partition: Availability. Else: Consistency. → MongoDB with read-preference primary (linearizable reads normally, but AP during partition). Uncommon combination.
hybrid
PC/EL
Under Partition: Consistency (reject writes). Else: Latency. → Most SQL DBs with async replicas. Consistency when possible; latency-optimised replicas otherwise.
hybrid
PC/EC
Under Partition: Consistency. Else: Consistency. → Spanner, CockroachDB, HBase, ZooKeeper. Always linearizable; you pay the latency for cross-region consensus.
CP/linearizable

The consistency spectrum (Jepsen's chart, simplified)

Linearizable

Strongest · single-machine feel

  • As if all operations happened one-by-one in real time order
  • The gold standard: Spanner, etcd, ZooKeeper, CockroachDB
  • Costs latency (2-3 network round-trips minimum for writes)
  • Fit: leader election, distributed locks, financial ledgers
Bounded staleness

‘Stale but no more than K seconds’

  • You'll see writes at most K seconds old (or K operations behind)
  • Cosmos DB's ‘bounded staleness’ level
  • Fit: dashboards, product catalogues where a bit of lag is fine
Session

Read-your-own-writes

  • You see YOUR writes immediately; others may lag
  • Cosmos DB default; ‘causal consistency’ is a close cousin
  • Fit: user profiles, shopping carts — user must see their edits
Eventual

‘Someday, all reads will agree’

  • Different clients see different values for a while
  • Dynamo, Cassandra default; S3 for many years
  • Cheapest, fastest, hardest to reason about
  • Fit: view counters, non-critical feeds, backups

A classification cheat sheet

1
Postgres (single primary)

PC/EC in the partition domain (primary unreachable = writes rejected). Replicas serve stale reads for latency = PC/EL if you enable that.

2
MongoDB (majority write concern)

PC/EC — writes need majority ack, reads can go to secondary with staleness allowance.

3
DynamoDB

AP by default (‘eventually consistent reads’). Also supports strongly-consistent reads = PC when you opt in per-request.

4
Cassandra

AP; tunable consistency per-request (QUORUM, ONE, ALL). Classic PA/EL.

5
Spanner / CockroachDB

PC/EC — global linearizability via consensus (Paxos/Raft) + tightly-synchronized clocks. You pay the latency for the guarantee.

6
Cosmos DB

Explicit 5 levels. Session (default) = PA/EL for most reads with per-session read-your-write.


Common misconception
✗ What most people think

"CAP says pick two of Consistency, Availability, Partition tolerance. My datacenter network is reliable, so I'll take CA."

✓ What is actually true

Partitions are not a choice — they are a property of the network, and any network can partition. So P is not optional, and CAP reduces to a single decision made during a partition: refuse requests to preserve consistency, or serve them and accept divergence. A "CA system" is just a system that has not yet had a partition, and will behave badly when it does.

Why the myth is so sticky

The myth is sticky because the theorem is stated as three symmetric letters, which invites reading it as a menu. It also matches lived experience: within one rack the network really is reliable most of the time, so CA feels like a defensible engineering judgement. It fails because partitions include far more than cable failures — a saturated NIC, a long GC pause, an overloaded switch, or a misapplied firewall rule are all indistinguishable from a partition to the nodes involved. Partition tolerance is not about surviving cut cables; it is about behaving correctly when a node cannot tell whether its peer is dead or merely unreachable.

Prove it to yourself

Ask the only CAP question that has an operational answer:

A partition splits your cluster. A write arrives at the
minority side. What happens?

(a) rejected / times out          -> you chose C
(b) accepted, reconciled later    -> you chose A
(c) nobody knows                  -> you have not chosen,
                                     the default has chosen
                                     for you

Most systems are (c) until an incident answers it. Find the answer by reading the configuration, not the marketing page.

From first principles
Start with the question

Why is the CAP tradeoff genuinely unavoidable? Surely a sufficiently clever protocol could do better.

  1. 1
    Consider two nodes holding a replica of the same value, with a network link between them, and a partition severing that link.
    forced by · this is the minimal case; if it is impossible here it is impossible in any larger system
  2. 2
    A write arrives at node A. Node A cannot reach node B, and cannot distinguish "B is down" from "B is alive but unreachable and possibly taking its own writes".
    forced by · a timeout is the only evidence available, and it is produced identically by both situations
  3. 3
    If A accepts the write, then a client reading from B sees the old value. The two replicas now disagree, so the system is not consistent in the linearizable sense.
    forced by · consistency requires all readers to observe a single agreed order of operations
  4. 4
    If A refuses the write until it can reach B, then A is unavailable for that request for the duration of the partition, which is unbounded.
    forced by · availability requires every non-failing node to answer, and A is not failing — it is choosing to refuse
  5. 5
    There is no third option, because A must either produce a response or not, and it has no information beyond what it can observe locally.
    forced by · a node cannot act on information it cannot obtain, and the partition is precisely what prevents obtaining it
⇒ Therefore

Therefore the tradeoff is a consequence of information, not of engineering skill. No protocol escapes it because no protocol can give A knowledge of B while the link is down.

And note what this predicts: since partitions are rare, a theorem about behaviour during partitions says nothing about the other 99.9% of the time — and yet systems clearly differ in normal operation too. That gap is exactly what PACELC fills: if Partitioned, choose Availability or Consistency; Else, choose Latency or Consistency. The second half is the one you feel every day, because synchronous replication to a distant replica costs a round trip on every single write whether or not anything has failed. CAP describes your worst day; PACELC describes your average one.

Mental modelTwo failure conversations, not one

Every distributed data system has two separate conversations. During a partition: do we refuse writes or accept divergence? During normal operation: do we wait for replicas to acknowledge, or answer immediately and replicate afterwards?

The second conversation happens on every request and determines your latency. The first happens rarely and determines whether an incident is a brief outage or a data reconciliation project.

  • Consistency in CAP means linearizability — every read sees the most recent write, as if there were one copy. It is not the C in ACID, which is about invariants within a transaction. The two words are unrelated and conflating them causes real confusion in design discussions.
  • Quorum systems choose C by construction: a majority must agree, so the minority side of a partition cannot make progress. This is why a three-node cluster survives one failure and a two-node cluster survives none — two nodes have no majority when split.
  • Availability in CAP is stricter than uptime. It means every non-failing node answers every request. A system that stays up but returns errors from the minority side has chosen C, regardless of what its availability dashboard reports.
  • The choice can be per-operation rather than per-system. Reading a product description and transferring money have completely different tolerances, and modern datastores expose per-request consistency levels precisely so you can make the decision where it belongs.
🔔 Fires when you see

Fire this model when you see: a vendor claiming to beat CAP · a two-node cluster described as highly available · writes accepted on both sides of a partition · a request latency floor that matches a cross-region round trip · someone choosing a database on consistency claims without asking about the else branch.

The tradeoff

Your service spans two regions. Does a write wait for the remote region to acknowledge before returning success?

Synchronous cross-region replication
+ you gain zero data loss on a region failure — an acknowledged write is durable in both regions, so failover is clean and requires no reconciliation. Recovery point objective is genuinely zero.
− you pay every write pays a full cross-region round trip, so write latency has a hard floor set by physics — tens to hundreds of milliseconds depending on the pair. Worse, the remote region's availability becomes your write availability: if it is unreachable, you cannot accept writes at all.
pick when financial ledgers and anything where losing an acknowledged write is unacceptable at any cost, and where write latency in the hundreds of milliseconds is tolerable
Asynchronous replication
+ you gain writes commit at local speed, so latency is unaffected by distance, and the remote region being down does not stop you accepting writes. Throughput is far higher because writes are not serialised behind a long round trip.
− you pay a window of unreplicated writes exists at all times. A region failure loses everything in that window, and a failover requires deciding what to do about writes that were acknowledged but never replicated — a reconciliation problem with no automatic answer.
pick when most workloads, when the business can state a tolerable data loss window and you can measure replication lag against it
Synchronous within region, asynchronous across regions
+ you gain zero data loss for the failure that actually happens most often — losing a node or a zone — with local write latency, while still holding a cross-region copy for the rare regional event.
− you pay two different durability guarantees in one system, which must be understood and communicated correctly. A regional failure still loses the async window, so the strong guarantee does not extend to the disaster you built the second region for.
pick when the default for most production systems: it matches the failure probability distribution rather than optimising for the rarest event
What a senior engineer actually does

Match the guarantee to the frequency of the failure. Zone failures are far more common than region failures, so pay for synchronous replication where it buys the most, and accept asynchronous across regions with an explicit, measured and alerted replication lag.

The decision that must never be left implicit is what happens at failover with unreplicated writes. If the answer is "we lose them silently", say so in writing and get agreement, because the alternative — discovering it during a failover while the business asks where the last four minutes of orders went — is how a technical event becomes an organisational one. And measure replication lag continuously: it is the direct, numerical statement of how much data you are currently prepared to lose.


(c) Hands-on · 25 min

We'll build a tiny Python simulator that demonstrates CAP mechanically: a two-node replicated store where you can toggle a partition, choose CP or AP behaviour, and watch reads/writes behave accordingly. No servers, just enough code to make the theorem tangible.

"""s070_cap_sim.py — a 100-line simulator to make CAP concrete.
 
Two nodes, one key. A client writes to Node A; Node A tries to replicate to Node B.
You control:
  * partition on/off  (drop messages between A and B)
  * mode: 'CP' (reject writes if replication fails) or 'AP' (accept and diverge)
 
Run:  python s070_cap_sim.py
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import time
 
 
@dataclass
class Node:
    name: str
    store: dict[str, str] = field(default_factory=dict)
 
 
@dataclass
class Cluster:
    a: Node
    b: Node
    partitioned: bool = False
    mode: str = "CP"           # "CP" or "AP"
    replication_delay_ms: int = 10
 
    # ---- write path ----
    def write(self, key: str, value: str) -> tuple[bool, str]:
        """Client always writes to node A. A tries to replicate to B."""
        self.a.store[key] = value
        if self.partitioned:
            if self.mode == "CP":
                # Consistency: undo local write, tell client we failed
                del self.a.store[key]
                return False, "REJECTED (partition, CP: won't accept without replica ack)"
            else:  # AP
                # Availability: accept, replicas will diverge
                return True, "ACCEPTED on A (partition, AP: will reconcile later)"
        # Normal path: replicate to B
        time.sleep(self.replication_delay_ms / 1000)
        self.b.store[key] = value
        return True, "ACCEPTED (replicated to B)"
 
    # ---- read path ----
    def read(self, key: str, from_node: str = "a") -> tuple[Optional[str], str]:
        node = self.a if from_node == "a" else self.b
        val = node.store.get(key)
        return val, f"read from {node.name.upper()}"
 
 
def print_state(c: Cluster) -> None:
    print(f"  A={c.a.store}  B={c.b.store}  partition={c.partitioned}  mode={c.mode}")
 
 
def main() -> None:
    c = Cluster(a=Node("a"), b=Node("b"))
 
    print("\n=== SCENARIO 1: normal write, replication OK ===")
    ok, msg = c.write("balance", "1000")
    print(f"write balance=1000 → {ok} · {msg}")
    print_state(c)
 
    print("\n=== SCENARIO 2: partition + CP mode ===")
    c.partitioned = True
    c.mode = "CP"
    ok, msg = c.write("balance", "1200")
    print(f"write balance=1200 → {ok} · {msg}")
    print_state(c)
    print("  Read from A:", c.read("balance", "a"))
    print("  Read from B:", c.read("balance", "b"))
    print("  Correct: system refused inconsistent write; both nodes still show old value.")
 
    print("\n=== SCENARIO 3: partition + AP mode (concurrent divergent writes) ===")
    c.mode = "AP"
    ok, msg = c.write("balance", "800")           # A accepts
    print(f"client-1 writes balance=800 on A → {ok} · {msg}")
    # Meanwhile a client hits B directly (simulate)
    c.b.store["balance"] = "1500"
    print("client-2 writes balance=1500 directly on B (available side)")
    print_state(c)
    print("  Read from A:", c.read("balance", "a"))
    print("  Read from B:", c.read("balance", "b"))
    print("  DIVERGENCE — same key, two values. Standard AP behaviour during partition.")
 
    print("\n=== SCENARIO 4: partition heals, we need conflict resolution ===")
    c.partitioned = False
    # Simplest strategy: last-write-wins by an imaginary timestamp
    print("  Applying LWW reconciliation (choose one)...")
    winner = c.b.store["balance"]                  # pretend B's write was later
    c.a.store["balance"] = winner
    print_state(c)
    print("  Note: LWW SILENTLY LOSES client-1's write. This is the ‘lost update’ pitfall of AP.")
    print("  Real systems use vector clocks / CRDTs / application-level merge to do better.")
 
    print("\n=== SCENARIO 5: PACELC — even without partition, replica reads may be stale ===")
    c.a.store["balance"] = "2000"
    print("write balance=2000 to A (no partition)")
    print("  IMMEDIATE read from A:", c.read("balance", "a"))
    print("  IMMEDIATE read from B (before replication):", c.read("balance", "b"),
          "← STALE — replication is async")
    time.sleep(c.replication_delay_ms / 1000)
    c.b.store["balance"] = "2000"                   # replication catches up
    print("  After replication delay, read from B:", c.read("balance", "b"))
 
 
if __name__ == "__main__":
    main()

What each block does

Anatomy of the simulator

Cluster.write · partitioned check
The fork in the road. If partitioned + CP → refuse (undo local, return failure). If partitioned + AP → accept locally and diverge. This IS CAP in code.
cap
Scenario 2 · CP under partition
System stays consistent by rejecting the write. Correctness preserved; availability sacrificed.
CP
Scenario 3 · AP under partition
Both nodes accept concurrent divergent writes. Availability preserved; the two ‘truths’ must be reconciled later.
AP
Scenario 4 · reconciliation lost update
Last-Write-Wins is the simplest strategy and silently loses concurrent updates. Real systems use vector clocks (Dynamo), CRDTs (Riak), or app-level merges (git!).
reconcile
Scenario 5 · PACELC in action
Even without a partition, the async replication window means node B's read is stale for 10ms. That's the L (latency) vs C (consistency) trade even when nothing is broken.
PACELC
Try itAdd per-request consistency (like DynamoDB's ‘strongly consistent read’)

Extend the simulator so read(key, strong=True) ignores the from_node param and always reads from A:

def read(self, key, from_node="a", strong=False):
    node = self.a if strong else (self.a if from_node == "a" else self.b)
    return node.store.get(key), f"read from {node.name.upper()} (strong={strong})"

Now retry scenario 5 with c.read("balance", "b", strong=True). Always returns the correct value — because strong reads go to A. Cost: you can't spread read load across replicas for the queries you marked strong. This is the exact trade DynamoDB makes with its ConsistentRead=true flag: 2× the RCU cost for strong reads.

💡 Hint · Add a `read(key, strong=False)` parameter. Strong reads MUST go to node A (the primary). Watch stale-read scenarios disappear at the cost of no-load-balancing across replicas.

(d) Production reality · 15 min

War story Amazon DynamoDB· 20155-hour outage · Sept 20
🔥 What broke

A metadata-service partition caused DynamoDB's internal metadata cache to become inconsistent. Because DynamoDB is AP, individual reads didn't error — they returned varying answers depending on which replica served them. The metadata inconsistency then cascaded to routing, and half of DynamoDB in us-east-1 went unavailable for hours.

Every AWS service that depended on DynamoDB (many) started failing.

🧯 The fix
Amazon rewrote the metadata layer to use a much smaller Paxos-based consistent store, isolating the AP data plane from the CP control plane. Post-mortem became a canonical AWS engineering blog.
🎓 Lesson to steal
AP is fine for data; the control plane that manages the AP system usually MUST be CP. Otherwise inconsistency in configuration compounds into cascading failures.
Post-mortem
War story MongoDB (multiple incidents pre-3.4)· 2014widespread
🔥 What broke
MongoDB's default write concern was w:1 (acknowledge on primary only, no replicas required). In a partition + primary failure, unacknowledged writes could be lost. Jepsen (Kyle Kingsbury) demonstrated this repeatedly in 2013-2015 posts that reshaped how the industry talks about consistency.
🧯 The fix
MongoDB 3.4+ changed the default write concern to w:majority. Users had to opt into the risky default. This is a case study in how a small default change can move a system from ‘AP with quiet lost writes’ to ‘CP with acknowledged durability’.
🎓 Lesson to steal
Defaults matter. A distributed database that ships with an unsafe default is a database that ships with an incident-per-month schedule at customer sites. Always read the durability section of your DB's docs; never trust ‘it works out of the box’.
Post-mortem
War story Google Spanner· 2012paper published
🔥 What broke
Google wanted a database with (a) SQL, (b) global reach, (c) linearizable transactions. Naïve implementation: 200 ms cross-region consensus per commit. Not viable.
🧯 The fix
Spanner's TrueTime API: every datacentre has GPS receivers and atomic clocks synced to bounded uncertainty (~7 ms). Timestamps are ‘intervals’ with a max-error bound. Commit waits out the uncertainty (typically ~7 ms). Result: externally-consistent transactions with commit latencies in the 10s of ms, not 100s.
🎓 Lesson to steal
CAP says you can't have both C and A during a partition. It says NOTHING about how much latency C costs. Investing in the physical layer (atomic clocks!) lets you compress the ‘cost’ of consistency dramatically. Most teams can't buy TrueTime, but the lesson generalises: infrastructure investment can move you along the L axis of PACELC.
Post-mortem

Common conceptual failure modes

  • ‘We're CA because we run in one datacentre’ — you're not distributed. Add a second node and you'll rediscover P immediately.
  • ‘We chose CP so we're safe’ — CP means you drop availability during partitions. Your SLO needs to reflect this; ‘99.999% CP’ is possible only if partitions are extremely rare.
  • ‘Eventual is fine, users won't notice’ — until user X sees ‘likes: 42’ on their profile and refreshes to ‘likes: 41’. Now they're confused. Session consistency (‘you see your own writes’) is the pragmatic default.
  • ‘Multi-master is like multi-primary Cassandra, right?’ — no. Cassandra with QUORUM writes is a specific consistency model. ‘Multi-master’ RDBMS often means active-active MySQL, which has different (weaker) guarantees. Vocabulary matters.

Where this shows up in the rest of the plan

CAP / PACELC informs every distributed-systems decision
S071 · Replication
Leader/follower vs multi-leader vs leaderless — different CAP/PACELC positions.
S072 · Consistency models
The full spectrum you'll classify systems on. This session is the theoretical grounding.
S073 · Consensus (Paxos, Raft)
How CP systems actually achieve consensus. Spanner, etcd, ZooKeeper.
S074 · Sharding & partitioning
How data is split across nodes; interacts with consistency choices.
S089 · System design · Twitter feed
AP feeds, CP account balance — pick per subsystem.
S095 · Multi-region deploys
Cross-region latency is a first-class PACELC concern.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. What's the CAP trap most people fall into? (‘pick 2 of 3’ — P is not optional)
  2. What does PACELC add that CAP alone misses?
  3. Give two real databases and their CAP + PACELC classification.

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.