---
title: PACELC
source: 'https://academia.sh/en/courses/introduction-to-system-design/pacelc'
course: 'Introduction to System Design'
language: en
updated: '2026-08-23T07:01:27+00:00'
license: 'CC BY-SA 4.0'
---

# PACELC

The latency trade-off outside a partition: naming the region where the CAP theorem stays silent, measuring the synchronous-acknowledgment and asynchronous-propagation policies with no partition in terms of the acknowledgment-wait step, messages per write, and the staleness window seen on reads, showing the sensitivity of the arrival-interval and latency assumptions, and classifying a system separately on its two branches.

One column in the previous lesson's measurement was left unexplained. Reject spent 12 messages,
respond spent 6; the difference came from every answered read being verified with the neighbor,
and those messages were sent while the link was up too. So the decision about consistency does
not end even if the network never partitions.

CAP is silent in this region: with no partition, the theorem has nothing to say. Yet the decision
is still there, paid on every request. The **PACELC** theorem fills the place where CAP stays
silent, and that is this lesson's subject.

## The Theorem's Second Branch

PACELC is a two-branch sentence. The first branch is what CAP says: if there is a **partition**
(P), a choice is made between **service availability** (A) and **consistency** (C). The second
branch is new: **else** (E — with no partition), a choice is made between **latency** (L) and
**consistency** (C).

The second branch's rationale is a simple fact: for a replica to verify its held value is current,
it must talk to its neighbor. That conversation crosses the network and has a cost, independent of
any partition — even with a perfectly healthy link, every verification needs a round trip. So
consistency is not free even with no outage; only the currency paid changes — not a rejected
request, but time waited.

## Two Write Policies With No Partition

The choice being measured lies on the write path. A carrier status event can be handled two ways:
have both replicas acknowledge every write (`acknowledged`), or write to one replica, acknowledge
the carrier right away, and propagate to the neighbor afterward (`propagated`). The model below
runs both policies over the same event sequence, with the link never down.

This is a model, not a measurement: a round is an abstract time step, latency `D` is a parameter
in rounds, and no clock is read anywhere — so the output is the same on every run.

```js
// pacelc/model.mjs — two WRITE policies while there is NO partition. A round is an abstract time
// step; the latency D is a model parameter in rounds, not a measured duration. Events for the
// same tracking number are ordered, so at most one write is ever in flight.
export function run({ rounds: N, latency: D, interval, policy }) {
  const write = new Map(); // writeNo -> { arrival, accepted, visible }
  const queue = [];
  let flight = null, messages = 0, seq = 0;

  for (let t = 1; t <= N; t++) {
    if ((t - 1) % interval === 0) {
      seq += 1;
      write.set(seq, { arrival: t, accepted: null, visible: null });
      queue.push(seq);
    }
    if (policy === "acknowledged") {
      if (flight !== null && flight.ends === t) {
        const w = write.get(flight.no);
        w.accepted = t;   // acknowledgment arrived: the write becomes visible on both replicas in the same round
        w.visible = t;
        messages += 2;    // outbound + acknowledgment
        flight = null;
      }
      if (flight === null && queue.length > 0) flight = { no: queue.shift(), ends: t + 2 * D };
    } else {
      while (queue.length > 0) {
        const w = write.get(queue.shift());
        w.accepted = t;       // written to the local replica, the carrier is acknowledged right away
        w.visible = t + D;    // reaches the neighbor D rounds later
        messages += 1;
      }
    }
  }

  const all = [...write.values()];
  const accepted = all.filter((w) => w.accepted !== null);
  const visible = accepted.filter((w) => w.visible <= N);
  const max = (a, f) => Math.max(0, ...a.map(f));
  const avg = (a, f) => (a.length === 0 ? 0 : a.reduce((t, w) => t + f(w), 0) / a.length);
  return {
    arrived: all.length, accepted: accepted.length, messages,
    avgWait: avg(accepted, (w) => w.accepted - w.arrival),
    messagesPerWrite: accepted.length === 0 ? 0 : messages / accepted.length,
    maxStaleness: max(visible, (w) => w.visible - w.accepted),
    maxEndToEnd: max(visible, (w) => w.visible - w.arrival),
  };
}
```

The model's four numbers each need their own definition. **Wait** is the difference between the
round an event arrives and the round the carrier is acknowledged. **Staleness window** is the
difference between the round a write is acknowledged and the round it becomes visible on the
second replica. **End-to-end** is the difference between arrival and visibility on the second
replica. **Messages** is the number spent per accepted write.

```js
// pacelc/measure.mjs — the trade-off on the E branch: two policies, two arrival intervals, three latencies
import { run } from "./model.mjs";

const ROUNDS = 24;
const pad = (x, n) => String(x).padStart(n);
console.log(`no partition. ${ROUNDS} rounds. arrival interval and latency D are assumptions; a round is an abstract step.`);
console.log();
console.log("interval | D | policy       | accepted | avg wait    | pending  | msgs/write  | staleness | end-to-end");
console.log("---------|---|--------------|----------|-------------|----------|-------------|-----------|-----------");
for (const interval of [1, 4]) {
  for (const D of [1, 2, 4]) {
    for (const policy of ["acknowledged", "propagated"]) {
      const r = run({ rounds: ROUNDS, latency: D, interval, policy });
      console.log(`${pad(interval, 8)} | ${pad(D, 1)} | ${policy.padEnd(12)} | ` +
        `${pad(`${r.accepted}/${r.arrived}`, 8)} | ${pad(r.avgWait.toFixed(1), 11)} | ` +
        `${pad(r.arrived - r.accepted, 8)} | ${pad(r.messagesPerWrite.toFixed(2), 11)} | ` +
        `${pad(`${r.maxStaleness} round`, 9)} | ${pad(`${r.maxEndToEnd} round`, 10)}`);
    }
  }
}
```

```sh
node pacelc/measure.mjs
```

```
no partition. 24 rounds. arrival interval and latency D are assumptions; a round is an abstract step.

interval | D | policy       | accepted | avg wait    | pending  | msgs/write  | staleness | end-to-end
---------|---|--------------|----------|-------------|----------|-------------|-----------|-----------
       1 | 1 | acknowledged |    11/24 |         7.0 |       13 |        2.00 |   0 round |   12 round
       1 | 1 | propagated   |    24/24 |         0.0 |        0 |        1.00 |   1 round |    1 round
       1 | 2 | acknowledged |     5/24 |        10.0 |       19 |        2.00 |   0 round |   16 round
       1 | 2 | propagated   |    24/24 |         0.0 |        0 |        1.00 |   2 round |    2 round
       1 | 4 | acknowledged |     2/24 |        11.5 |       22 |        2.00 |   0 round |   15 round
       1 | 4 | propagated   |    24/24 |         0.0 |        0 |        1.00 |   4 round |    4 round
       4 | 1 | acknowledged |      6/6 |         2.0 |        0 |        2.00 |   0 round |    2 round
       4 | 1 | propagated   |      6/6 |         0.0 |        0 |        1.00 |   1 round |    1 round
       4 | 2 | acknowledged |      5/6 |         4.0 |        1 |        2.00 |   0 round |    4 round
       4 | 2 | propagated   |      6/6 |         0.0 |        0 |        1.00 |   2 round |    2 round
       4 | 4 | acknowledged |      2/6 |        10.0 |        4 |        2.00 |   0 round |   12 round
       4 | 4 | propagated   |      6/6 |         0.0 |        0 |        1.00 |   4 round |    4 round
```

## Reading the Numbers

The two cleanest columns are staleness and messages. The acknowledged policy's staleness window
is 0 rounds in all six runs; the propagated policy's is exactly `D` rounds — 1, 2, and 4. This
turns the second branch's definition into a number: asynchronous propagation trades consistency
for a window as wide as the latency parameter. In exchange, messages per write drop from 2.00 to
1.00. The acknowledgment message is the fee consistency pays even with no outage.

The wait column shows who that fee is billed to. In the propagated policy, wait is 0.0 rounds in
every run: the carrier gets its acknowledgment right away. In the acknowledged policy, wait at
arrival interval 4 is exactly $2D$ rounds — 2.0 and 4.0. At sparse arrivals, the cost of
synchronous acknowledgment is predictable and constant.

The runs at arrival interval 1 show something quite different: wait climbs to 7.0, 10.0, and
11.5, and pending reaches 13, 19, and 22. The reason: events under the same tracking number must
stay ordered, so at most one write is ever in flight, and the policy can accept a write only once
every $2D$ rounds. Once the arrival interval falls below $2D$, the queue grows. This is the same
independence seen in the Latency and Throughput lesson: per-write latency stays fixed at $2D$
while throughput falls, and falling throughput inflates the wait. What to do with the queue —
reject it, slow it down, apply back pressure — is the subject of the Caching, Queues and
Asynchronous Processing course and is not revisited here.

There is a reading trap in the end-to-end column. At arrival interval 1 and $D = 4$, this number
is 15, while at $D = 2$ it is 16 — smaller, but not better: the column is computed only over
accepted writes, and in that row only 2 of 24 events were accepted. The
end-to-end latency of the 22 events still queued is not yet defined. Near saturation, an average
latency is misleading; the latency column should not be read before the pending column is.

Finally, the acknowledged policy's end-to-end number is not 0. Synchronous acknowledgment does
not let the reader see the truth sooner; it keeps the system from promising something it cannot
yet verify. Staleness comes out 0 because that measure sits between what the system promises and
what gets read. The distance between the event and the value read does not close — it just
becomes invisible.

## Classifying the Two Branches Separately

A system is not named with a single letter but classified separately for its two branches. The
read policies from the previous lesson and the write policies from this one produce four
combinations.

| Class | Under partition | With no partition | Policy pair |
|---|---|---|---|
| PC/EC | consistency | consistency | reject + acknowledged |
| PA/EL | service availability | latency | respond + propagated |
| PC/EL | consistency | latency | reject + propagated |
| PA/EC | service availability | consistency over latency | respond + acknowledged |

The third row looks strange at first but is a coherent design: writes propagate asynchronously
while the link is up, and a read is rejected once it goes down. The system is fast, and instead of
getting something wrong during a partition it stays silent; the price is a `D`-round staleness
window even while the link is up.

Classification, again, is per flow rather than for the whole system. In the tracking service, the
tracking query can choose the PA/EL branch and end-of-day billing the PC/EC branch; both run over
the same replicas. Billing a shipment against a stale status produces a wrong invoice even within
a two-round window; a tracking query showing a status two rounds stale is a tolerable error.

## Summary

- PACELC is a two-branch sentence: under a partition (P), the choice is between service
  availability (A) and consistency (C); with no partition (E), between latency (L) and
  consistency (C).
- In the model, the acknowledged policy's staleness window was 0 rounds in every run; the
  propagated policy's was exactly as wide as the latency parameter — 1, 2, and 4 rounds.
- Consistency's fee outside a partition showed up in the message column: 2.00 messages per write
  versus 1.00.
- At arrival interval 4, the acknowledged policy's wait stayed fixed at $2D$ rounds (2.0 and 4.0);
  once the interval dropped to 1, the queue grew and wait climbed from 7.0 to 11.5, with pending
  events rising from 13 to 22.
- Synchronous acknowledgment does not let the reader see the truth sooner; end-to-end latency
  remains — the system only stops promising a value it cannot verify.
- Classification is done separately for the two branches and per flow: the tracking query can sit
  on the PA/EL branch, billing on the PC/EC branch.

## Next Step

In this lesson's measurement, the staleness window collapsed to a single number: 0 or `D` rounds.
That is only the coarse shape of the problem. What a reader sees inside a `D`-round window depends
less on the window's width than on which guarantee is given. Two clients querying the same
shipment at once can see different statuses; one can see a sequence that goes backward instead of
forward; a third can fail to find the event it just wrote on its next query. All of this happens
inside the same `D` window, and none of it substitutes for the rest. The next lesson names these
guarantees — strong, weak, and eventual consistency — reading the same event sequence under three
read policies and counting how often different values are seen, how many rounds convergence
takes, and how many monotonic-read violations occur.
