Problem Context
Distributed systems force you to pick: when a network partition splits your replicas, do you keep accepting writes (and risk diverging state) or refuse them (and stay correct, at the cost of availability)? Twenty-five years of CAP, PACELC, and a parade of failed banks/booking systems have refined the answer: different parts of your system want different consistency models, and the real skill is matching the model to the workload.
This guide is the consistency map โ the seven models you actually meet in production, what each one buys you, what they cost in latency and availability, and how to mix them in a single system without lying to your users.
- You used "eventual consistency" as an excuse for a bug
- You can't explain the difference between linearizable and serializable
- You read your own write and saw the old value, and assumed the cache was broken
- Your Cosmos DB account is set to "Strong" everywhere because "safer"
This is the consistency menu โ what each model actually guarantees, what it costs, and how to match it to your workload.
Concept Explanation
From strongest to weakest:
- Linearizable (Strong) โ every operation appears to take effect at a single point in time, in real-time order. Reads always see the latest committed write. Costs: cross-region round-trips, no availability under partition.
- Sequential โ all clients see operations in the same order, but not necessarily real-time order.
- Causal โ if A happened-before B, every client sees A before B. Concurrent ops can be seen in any order. Best balance for collaborative apps.
- Bounded stalenessโ reads may be stale, but bounded by N versions or T seconds. Cosmos DB's most popular setting.
- Read-your-writes(session) โ a client always sees its own writes; other clients may not yet. The model people actually want when they say "strong".
- Monotonic reads โ successive reads from one client never go backwards in time.
- Eventual โ replicas converge eventually, no ordering guarantees in the meantime. Cheapest, fastest, most surprising.
flowchart LR
subgraph Strong["Strong (Linearizable)"]
L1["Read sees latest write<br/>cross-region quorum<br/>~ms-tens of ms"]
end
subgraph Bounded["Bounded Staleness"]
L2["Stale by โค K ops or โค T sec<br/>predictable lag"]
end
subgraph Session["Session (RYW)"]
L3["You see your own writes<br/>cheap, default in many DBs"]
end
subgraph Eventual["Eventual"]
L4["Replicas converge eventually<br/>cheapest, no ordering"]
end
Strong -->|cost โ availability โ| Bounded
Bounded --> Session
Session --> Eventual
style Strong fill:#dc2626,color:#fff,stroke:#b91c1c
style Eventual fill:#16a34a,color:#fff,stroke:#15803d
Implementation
Step 1: Pick the model per workload, not per database
Workload Right model
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโ
Money transfer / inventory decrement Linearizable (Strong)
Order status user just submitted Read-your-writes (Session)
User profile, preferences Bounded staleness (5s)
Like counts, view counts Eventual
Collaborative doc editing Causal
Audit log / event store Monotonic reads
Catalog browsing Eventual + cacheStep 2: Cosmos DB โ set per-account default, override per-request
// Account default: Session (read-your-writes) โ usually right for SaaS
var client = new CosmosClient(endpoint, credential, new CosmosClientOptions
{
ConsistencyLevel = ConsistencyLevel.Session
});
// Override for a single linearizable read (e.g., re-check inventory before charge)
var resp = await container.ReadItemAsync<Item>(id, new PartitionKey(tenantId),
new ItemRequestOptions { ConsistencyLevel = ConsistencyLevel.Strong });
// Override down for a "good enough" report
var weak = await container.ReadItemAsync<Item>(id, new PartitionKey(tenantId),
new ItemRequestOptions { ConsistencyLevel = ConsistencyLevel.Eventual });Step 3: SQL transactions โ pick an isolation level deliberately
READ UNCOMMITTED โ dirty reads. Don't.
READ COMMITTED โ most DBs default. Allows non-repeatable reads, phantoms.
REPEATABLE READ โ same row reads identical within a tx. Phantoms still possible.
SNAPSHOT โ point-in-time view via row versions. Excellent default for OLTP.
SERIALIZABLE โ equivalent to some serial schedule. Strongest, slowest.
For Azure SQL / Postgres OLTP work: SNAPSHOT (Postgres calls it REPEATABLE READ
in MVCC). For financial / inventory invariants: SERIALIZABLE on the critical tx.using var tx = await conn.BeginTransactionAsync(IsolationLevel.Serializable);
try
{
var stock = await conn.QuerySingleAsync<int>(
"SELECT stock FROM products WHERE id=@id", new { id }, tx);
if (stock < qty) throw new OutOfStockException();
await conn.ExecuteAsync(
"UPDATE products SET stock = stock - @qty WHERE id=@id", new { qty, id }, tx);
await tx.CommitAsync();
}
catch { await tx.RollbackAsync(); throw; }Step 4: Read-your-writes across replicas
The classic bug:
POST /api/orders โ primary (writes)
GET /api/orders โ read replica (lags by 200ms)
โ user sees empty list right after submitting
Fixes (any one is enough):
โข Read from primary for N seconds after a write (sticky read)
โข Pass a "session token" / LSN to the replica; replica waits to catch up
โข Apply the change optimistically client-side (update UI, sync later)Step 5: Causal consistency in the wild โ version vectors
// Each user's edit carries a vector clock; merge on read
public record VectorClock(Dictionary<string, long> Clocks)
{
public bool HappensBefore(VectorClock other)
{
bool any = false;
foreach (var (node, t) in Clocks)
{
var o = other.Clocks.GetValueOrDefault(node, 0);
if (t > o) return false;
if (t < o) any = true;
}
return any;
}
}
// If neither HappensBefore the other โ concurrent โ present both to the user
// (or merge with a CRDT)Step 6: Sagas instead of distributed transactions
Two-phase commit across services is a cascading-failure magnet.
Saga pattern:
1. ChargeCard โ RefundCard (compensating action)
2. ReserveInventory โ ReleaseInventory
3. CreateShipment โ CancelShipment
Each step commits independently. On failure, run compensations in reverse.
Eventually consistent across services; strong within each service's DB.Pitfalls
1. Confusing serializable (transactions) with linearizable (reads/writes). Serializable is about transaction ordering; linearizable is about real-time ordering of single ops. SQL Serializable does not imply linearizable across replicas.
2. "Strong everywhere because safer." Cosmos DB Strong forces cross-region quorum on every read โ your latency triples and your write availability halves. Use Session by default; Strong only on the operations that actually need it.
3. Reading from a replica right after writing.Replicas lag (10ms - several seconds). The user's POST returns and the immediate GET returns stale state. Pin reads to primary briefly, or pass a session token.
4. Believing the cache is "strongly consistent". Caches are eventually consistent by definition. Invalidate on the write path, version your keys, and never use a cache as the source of truth.
5. Distributed locks for correctness. Redis SETNX, Zookeeper locks โ all of them have edge cases under partition and clock skew. Either use a real consensus protocol (Raft) or design the operation to not need a lock (idempotent + optimistic concurrency).
6. Two-phase commit across services. Locks rows on N machines for the duration; one slow participant blocks everyone. Use sagas with compensating actions instead.
Practical Takeaways
- Match the model to the operation. A single system can mix Strong, Session, and Eventual โ and should.
- Cosmos DB default: Session. Override per-request to Strong only when you need linearizable invariants.
- SQL OLTP default: Snapshot isolation. Reach for Serializable on the few transactions that enforce invariants.
- Read-your-writes is what users actually expect. Pin to primary or use session tokens to deliver it.
- For collaborative editing, causal + CRDTs beat strong + locks.
- Avoid distributed transactions. Use the saga pattern for cross-service workflows.
- Document the consistency model your API offers. Vague guarantees become support tickets.

