CAP told you what breaks.
PACELC tells you what you choose every second.
The CAP theorem only applies during a disaster. Partitions happen maybe twelve times a year. PACELC covers the other 525,948 minutes: the tradeoff that never stops, even when everything is working perfectly.
The two tradeoffs
You learned the CAP theorem. Consistency, availability, partition tolerance: pick two. And you felt smart. Then someone asked: but what about when the network isn't broken? CAP had nothing to say, because CAP only describes disasters, and partitions are rare.
But every single request that hits your system when everything is working fine still faces a choice: speed or correctness.
In 2012 a computer scientist named Daniel Abadi looked at the CAP theorem and said: Brewer was right, but he only told half the story. So he extended it. If there is a Partition, choose between Availability and Consistency. Else, when everything is normal, choose between Latency and Consistency. Four letters: PA/EL or PC/EC. The most honest description of every database ever built. Once you see it, you find it everywhere: in your own code, in every system you have ever used, every day of your career.
CAP describes what happens when your network breaks. PACELC extends it with a second question: what happens the rest of the time? Even during perfect network conditions, every distributed system still chooses between speed and correctness on every single request.
The PACELC decision tree
network partition detected?
├── YES → CAP applies
│ choose: Availability (A) or Consistency (C)
│
└── NO → Else (E) applies
choose: Latency (L) "answer fast, maybe stale"
or Consistency (C) "answer correct, takes longer"
The four labels
The balance query, two ways
The choice, expressed as types
Like the CAP page, the choice is structural. A read either returns a possibly-stale local value (EL) or pays to verify with the primary (EC).
// PACELC expressed as a type system.
// Every read operation implicitly makes this choice.
#[derive(Debug)]
enum PacelcRead {
// EL: answer fast, tolerate staleness
LowLatency { max_staleness_ms: u64 },
// EC: answer correct, pay the latency cost
StrongConsistency { require_quorum: bool },
}
fn read_balance(
strategy: &PacelcRead,
local_cache: u64,
local_age_ms: u64,
fetch_from_primary: impl Fn() -> u64,
) -> u64 {
match strategy {
// EL: return local value if fresh enough
PacelcRead::LowLatency { max_staleness_ms } => {
if local_age_ms <= *max_staleness_ms {
local_cache // fast, possibly stale
} else {
fetch_from_primary() // cache expired
}
}
// EC: always fetch from primary
PacelcRead::StrongConsistency { .. } => {
fetch_from_primary() // slow, always correct
}
}
}
fn main() {
let el = PacelcRead::LowLatency { max_staleness_ms: 100 };
let ec = PacelcRead::StrongConsistency { require_quorum: true };
// EL: returns cache if < 100ms old. Fast. Instagram does this.
let _ = read_balance(&el, 500, 50, || 500);
// EC: always goes to primary. Correct. Banks do this.
let _ = read_balance(&ec, 500, 50, || 500);
}#include <stdint.h>
#include <stdbool.h>
typedef enum {
PACELC_EL, // low latency, tolerate staleness
PACELC_EC // strong consistency, pay latency
} PacelcStrategy;
typedef struct {
uint64_t value;
uint64_t age_ms;
} CachedValue;
typedef uint64_t (*FetchPrimary)(void);
uint64_t read_balance(
PacelcStrategy strategy,
CachedValue cache,
uint64_t max_staleness_ms,
FetchPrimary fetch
) {
switch (strategy) {
case PACELC_EL:
// EL: use cache if fresh enough
if (cache.age_ms <= max_staleness_ms) {
return cache.value; // fast, maybe stale
}
return fetch(); // cache expired, go remote
case PACELC_EC:
// EC: always go to primary
return fetch(); // slow, always correct
default:
return fetch();
}
}Feel it: send a read under each strategy
Same setup as the CAP visualiser, no partition this time. A primary and a nearby replica. Update the primary so the replica falls behind, then send reads under EL and EC and watch the latency-versus-correctness tradeoff happen in milliseconds.
What every system actually chose
Every database, every distributed system, every blockchain has a PACELC label: four letters that describe its entire design philosophy. Here is what the most important ones chose, and why.
The labels, side by side
| system | partition | normal | speed | correctness |
|---|---|---|---|---|
| DynamoDB | PA | EL | ★★★★★ | ★★★ |
| HBase | PC | EC | ★★★ | ★★★★★ |
| Cassandra | PA* | EL* | ★★★★ | ★★★★ |
| MySQL | PC | EC | ★★★ | ★★★★★ |
| Bitcoin | PC | EC | ★ | ★★★★★ |
| Solana | PA | EL | ★★★★★ | ★★★ |
| Ethereum | PC | EC | ★★★ | ★★★★★ |
Tunable consistency, like Cassandra
The most flexible systems let you pick the PACELC tradeoff per query. ONE is EL (fast, may be stale), ALL is EC (slow, refuses if nodes disagree), QUORUM sits in between. Same database, different point on the spectrum for every read.
use std::collections::HashMap;
#[derive(Debug, Clone, Copy)]
enum ConsistencyLevel {
One, // EL: fastest, least consistent
Quorum, // balanced: majority must agree
All, // EC: slowest, most consistent
}
struct DistributedKV {
nodes: Vec<HashMap<String, u64>>,
}
impl DistributedKV {
fn read(&self, key: &str, level: ConsistencyLevel) -> Option<u64> {
let responses: Vec<Option<u64>> = self
.nodes
.iter()
.map(|node| node.get(key).copied())
.collect();
match level {
// EL: return first available response. Fast, may be stale.
ConsistencyLevel::One => responses.iter().flatten().next().copied(),
// Balanced: a majority must agree.
ConsistencyLevel::Quorum => {
let quorum = self.nodes.len() / 2 + 1;
let mut counts: HashMap<u64, usize> = HashMap::new();
for val in responses.iter().flatten() {
*counts.entry(*val).or_insert(0) += 1;
}
counts
.into_iter()
.find(|(_, count)| *count >= quorum)
.map(|(val, _)| val)
}
// EC: ALL nodes must agree. Slowest, most consistent.
ConsistencyLevel::All => {
let values: Vec<u64> = responses.iter().flatten().copied().collect();
if values.len() == self.nodes.len()
&& values.windows(2).all(|w| w[0] == w[1])
{
values.first().copied()
} else {
None // nodes disagree: refuse
}
}
}
}
}
fn main() {
let db = DistributedKV {
nodes: vec![
[("balance".to_string(), 500)].into(),
[("balance".to_string(), 500)].into(),
[("balance".to_string(), 495)].into(), // stale
],
};
println!("{:?}", db.read("balance", ConsistencyLevel::One)); // Some(500)
println!("{:?}", db.read("balance", ConsistencyLevel::Quorum)); // Some(500)
println!("{:?}", db.read("balance", ConsistencyLevel::All)); // None
}#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
typedef enum {
CONSISTENCY_ONE, // EL: fastest
CONSISTENCY_QUORUM, // balanced
CONSISTENCY_ALL // EC: most consistent
} ConsistencyLevel;
// Returns -1 if consistency cannot be guaranteed.
int64_t distributed_read(
const int64_t *node_values, // -1 = unreachable
size_t node_count,
ConsistencyLevel level
) {
size_t quorum = node_count / 2 + 1;
size_t reachable = 0, agreements = 0;
int64_t first_value = -1;
for (size_t i = 0; i < node_count; i++) {
if (node_values[i] < 0) continue;
reachable++;
if (first_value < 0) first_value = node_values[i];
if (node_values[i] == first_value) agreements++;
}
switch (level) {
case CONSISTENCY_ONE:
return first_value; // EL: first wins
case CONSISTENCY_QUORUM:
return (agreements >= quorum) ? first_value : -1;
case CONSISTENCY_ALL:
return (reachable == node_count && agreements == node_count)
? first_value : -1;
}
return -1;
}
int main(void) {
int64_t nodes[] = {500, 500, 495}; // two agree, one stale
printf("ONE: %lld\n", distributed_read(nodes, 3, CONSISTENCY_ONE)); // 500
printf("QUORUM: %lld\n", distributed_read(nodes, 3, CONSISTENCY_QUORUM)); // 500
printf("ALL: %lld\n", distributed_read(nodes, 3, CONSISTENCY_ALL)); // -1
return 0;
}ALL path returning None / -1 when nodes disagree is EC in its purest form: it would rather refuse than hand back a value it cannot fully verify. The ONE path never refuses. Same code, same data, opposite ends of the PACELC spectrum, chosen at call time.PACELC in your own code
Most developers think PACELC is a database concern. It isn't. You make PACELC decisions in every codebase you touch, every day, without knowing it.
You have been doing this all along
Stale-while-revalidate vs always-fresh
The two most common PACELC patterns in application code, side by side. el_read returns instantly and refreshes in the background; ec_read blocks until the data is fresh. The difference is one spawn versus one blocking call.
use std::time::{Duration, Instant};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct Cache<T: Clone> {
value: T,
fetched_at: Instant,
ttl: Duration,
}
// PA/EL: stale-while-revalidate.
// Returns immediately, refreshes in the background.
fn el_read<T: Clone + Send + 'static>(
cache: Arc<Mutex<Cache<T>>>,
fetch: impl Fn() -> T + Send + 'static,
) -> T {
let cached = cache.lock().unwrap();
if cached.fetched_at.elapsed() > cached.ttl {
// Stale: trigger a background refresh, but do not wait.
let cache_clone = Arc::clone(&cache);
std::thread::spawn(move || {
let fresh = fetch();
let mut c = cache_clone.lock().unwrap();
c.value = fresh;
c.fetched_at = Instant::now();
});
}
cached.value.clone() // EL: fast, possibly stale
}
// PC/EC: always consistent.
// Blocks until fresh data is fetched.
fn ec_read<T: Clone>(
cache: Arc<Mutex<Cache<T>>>,
fetch: impl Fn() -> T,
) -> T {
let mut cached = cache.lock().unwrap();
if cached.fetched_at.elapsed() > cached.ttl {
cached.value = fetch(); // EC: slow, always correct
cached.fetched_at = Instant::now();
}
cached.value.clone()
}#include <time.h>
#include <stdbool.h>
#include <stdint.h>
#include <pthread.h>
typedef struct {
uint64_t value;
time_t fetched_at;
int ttl_seconds;
pthread_mutex_t lock;
} Cache;
typedef uint64_t (*FetchFn)(void);
// PA/EL: return immediately, refresh in the background.
uint64_t el_read(Cache *cache, FetchFn fetch) {
pthread_mutex_lock(&cache->lock);
uint64_t result = cache->value;
bool stale = difftime(time(NULL), cache->fetched_at) > cache->ttl_seconds;
pthread_mutex_unlock(&cache->lock);
if (stale) {
// Simplified: real code hands this to a thread pool.
uint64_t fresh = fetch();
pthread_mutex_lock(&cache->lock);
cache->value = fresh;
cache->fetched_at = time(NULL);
pthread_mutex_unlock(&cache->lock);
}
return result; // EL: return before the refresh completes
}
// PC/EC: block until fresh.
uint64_t ec_read(Cache *cache, FetchFn fetch) {
pthread_mutex_lock(&cache->lock);
if (difftime(time(NULL), cache->fetched_at) > cache->ttl_seconds) {
cache->value = fetch(); // EC: block and fetch fresh data
cache->fetched_at = time(NULL);
}
uint64_t result = cache->value;
pthread_mutex_unlock(&cache->lock);
return result; // EC: always current
}PACELC across the blockchain ecosystem
Every blockchain is a PACELC opinion. Read the four letters and you understand the entire design.
- Bitcoin (PC/EC): consistency above everything. Every transaction waits for global consensus across ten thousand nodes, ten minutes per block. The latency is the proof. No shortcut, no exception, no stale reads.
- Ethereum (PC/EC, strengthening): started with longer finality windows; Proof of Stake introduced deterministic finality after two epochs (about twelve minutes). Moving deliberately toward stronger consistency because DeFi contracts cannot tolerate reversal.
- Solana (PA/EL): Proof of History provides a cryptographic clock, so nodes agree on ordering without waiting for each other. 400ms confirmations, 1500x faster than Bitcoin. The tradeoff: outages Bitcoin has never had. Optimise EL to the physical limit and partition tolerance weakens under load.
- Sui and Aptos (hybrid): simple transfers go PA/EL, skipping global consensus to settle in under a second; complex transactions go PC/EC, requiring global agreement and taking longer but always correct. The most nuanced PACELC answer yet: a different tradeoff per operation type.
// PACELC as a design tool
// Ask this about every system you build:
//
// IF partition:
// choose A (stay online, risk divergence)
// or C (go offline, guarantee truth)
//
// ELSE normal operation:
// choose L (answer fast, risk staleness)
// or C (answer correct, pay latency)
//
// Your answer defines your architecture.
// Everything else follows from it.
Where PACELC appears in BitRoot
The latency-versus-consistency tradeoff is not unique to databases. It shows up at every layer of the stack: