Consistency has a cost
Strong consistency serves the paths where correctness depends on one current answer. Bounded lag serves the product paths where the promise is explicit.
Consistency bugs are quiet at the start.
A user changes their display name and sees the old one on another screen. A follower count dips, then climbs back. A search result still shows a deleted post for a few seconds. An inventory number says one item remains, but checkout fails.
It is tempting to treat each of these as its own small puzzle, and each one does need its own fix. But underneath them sits a single question, and it is narrower than it first appears:
What promise does this read need from the write that came before it?
Sometimes the answer is "the latest value, always." Sometimes it is "the user's own write should be visible to them." Sometimes it is "within a few seconds is fine." Once the promise is named, the architecture has something to follow. Most of this post is just learning to ask that question a little earlier.
Start with the read promise
Consistency is a promise made by a read path. Not a property of the database, not a setting to turn up. A promise one read makes about what it will show.
These are the common ones:
| Promise | Meaning | Common fit |
|---|---|---|
| Strong consistency | A read observes the latest committed write | Balances, locks, inventory, uniqueness |
| Read-your-writes | The writer sees their own update immediately | Profile edits, drafts, settings |
| Monotonic reads | A user does not go backward after seeing a newer value | Timelines, document versions |
| Bounded staleness | Data can lag by a known amount | Search, feeds, counters |
| Eventual consistency | Replicas or projections converge later | Analytics, rankings, social counts |
Strong consistency feels safer, so it is easy to reach for everywhere. Eventual consistency feels scalable, so it is easy to reach for everywhere. Both instincts are reasonable, and both go wrong when applied without a reason.
Each level only earns its meaning from the product promise behind it. The level is the answer; the promise is the question.
The display name bug
Here is a common one, traced step by step:
1. User updates display name from "Mina" to "Mina K."
2. API writes the leader database.
3. Profile page reads from a replica.
4. Replica is 800ms behind.
5. User sees the old name and retries the update.
The write worked. Nothing was lost. What failed was the read promise: the writer expected to see their own change, and the replica had not caught up yet.
There are several fixes, and they cost different things:
| Fix | Cost | When it fits |
|---|---|---|
| Read from leader after write | More leader load | The writer must see the latest value |
| Sticky session to same replica | More routing state | Replicas can preserve session order |
| Return updated value from write response | Less follow-up read | UI only needs the changed fields |
| Show "saving" until projection catches up | Product accepts pending state | Derived views update asynchronously |
| Wait for replica apply before success | Higher write latency | Read-after-write must work from replicas |
The fix follows from one question: which read went stale, for whom, and for how long? Answer that, and the right row in the table usually picks itself.
CAP, the useful part
The simplified CAP line is usually "consistency, availability, partition tolerance: pick two." It is memorable, and on its own it does not help much with a real decision.
The version that does help asks one thing:
When the network is unreliable, should this operation reject work or serve a possibly stale answer?
Partitions rarely arrive as a dramatic outage. They take quiet forms. A replica falls behind. A region loses a link. A queue consumer pauses. A deployment routes traffic unevenly. In each of these, the system has to choose, and it is better if you chose first.
For an account balance write, reject or wait.
For a follower count read, serve the stale count.
For a permission check, read the authoritative source or fail closed.
For a recommendation module, return an empty list or a cached result.
CAP earns its keep when it pushes this decision into the design, where you can make it calmly, rather than into an incident, where you cannot.
Strong consistency is coordination
Strong consistency means the system coordinates before it answers. That is the whole idea, and it is also the whole cost.
The costs are concrete and worth naming plainly:
- Extra network round trips.
- More time spent holding locks or transactions open.
- Lower availability during replica or region problems.
- More pressure on the leader or quorum.
- More complicated failover behavior.
None of these are bugs. They are the price of agreement, and that price is exactly why strong consistency is worth reserving for the places that genuinely need it: the invariants.
Moving credits between accounts is one of those places.
def transfer_credits(from_account_id, to_account_id, amount):
with db.transaction() as tx:
debited = tx.execute(
"""
UPDATE accounts
SET balance = balance - %(amount)s
WHERE id = %(from_account_id)s
AND balance >= %(amount)s
""",
{
"from_account_id": from_account_id,
"amount": amount,
},
)
if debited.row_count != 1:
raise InsufficientFunds()
tx.execute(
"""
UPDATE accounts
SET balance = balance + %(amount)s
WHERE id = %(to_account_id)s
""",
{
"to_account_id": to_account_id,
"amount": amount,
},
)
tx.insert_ledger_entry(
from_account_id=from_account_id,
to_account_id=to_account_id,
amount=amount,
)
The transaction is the consistency boundary. Everything inside it agrees before anything is acknowledged.
This path reads from the leader to decide whether money can move, because a stale read here does not produce a slightly old screen. It produces an incorrect balance, and that is the kind of mistake the coordination exists to prevent.
Eventual consistency is a lag budget
Eventual consistency is gentler, and it deserves a clear definition rather than a vague hope:
This derived value may lag the source of truth by up to N seconds.
If lag exceeds N, the product changes behavior.
"Eventual" is honest only when N is a number you have chosen and can see. For a public profile card, the source of truth might be users, while search, feed headers, and mention previews update from an event.
def update_profile(user_id, display_name):
with db.transaction() as tx:
version = tx.next_profile_version(user_id)
tx.update_user_profile(
user_id=user_id,
display_name=display_name,
version=version,
)
tx.insert_outbox_event(
event_type="profile.updated",
aggregate_id=user_id,
payload={
"user_id": user_id,
"version": version,
},
)
return {
"user_id": user_id,
"display_name": display_name,
"version": version,
}
Workers update derived views:
def handle_profile_updated(event):
user_id = event.payload["user_id"]
version = event.payload["version"]
profile = db.get_user_profile(user_id)
search.upsert_user(
user_id=user_id,
display_name=profile.display_name,
source_version=version,
)
cache.delete(f"profile_card:v3:user:{user_id}")
Search may lag, and that is fine by design. The profile edit response returns on its own as soon as the write commits, and search catches up within its lag budget.
The part that matters is that the lag is measured, not assumed.
Read-your-writes scoped to the writer
A lot of product paths ask for less than they seem to. The writer needs to see their own latest write. Everyone else can see it a little later, and usually does not notice.
A simple pattern covers this: return a version from the write, then carry it into the next read.
def update_settings(user_id, settings):
version = db.update_settings(
user_id=user_id,
settings=settings,
)
return {
"settings": settings,
"version": version,
}
def get_settings(user_id, min_version=None):
if min_version is None:
return replicas.any().get_settings(user_id)
replica = replicas.find_one_with_version_at_least(
user_id=user_id,
version=min_version,
timeout_ms=80,
)
if replica is not None:
return replica.get_settings(user_id)
return db.leader().get_settings(user_id)
Normal reads stay cheap. Only the reads that require a known version pay the cost of the leader or the wait, and those are the minority.
This works because of a quiet fact about traffic: most reads arrive well after the write they might have chased, so they have nothing to wait for.
Quorums
Quorums take the read/write tradeoff and make it arithmetic, which is a relief, because arithmetic is easy to reason about.
With N replicas:
W = number of replicas that must confirm a write
R = number of replicas that must answer a read
If W + R > N, the read set and the write set must share at least one replica. In the simple case, that shared replica is what lets a read observe the latest write.
For N = 3, the shapes look like this:
| W | R | Shape |
|---|---|---|
| 1 | 1 | Fast, with stale reads common |
| 2 | 1 | Writes wait for a majority, reads can still be stale |
| 1 | 2 | Writes are fast, reads compare more replicas |
| 2 | 2 | Read and write quorums overlap |
| 3 | 1 | Writes are slow, reads are cheap |
The idea, stripped down to what it is:
def quorum_intersects(replica_count, write_confirmations, read_confirmations):
return write_confirmations + read_confirmations > replica_count
assert quorum_intersects(3, 2, 2)
assert not quorum_intersects(3, 1, 1)
Real systems carry far more than this. Clocks, leader terms, hinted handoff, read repair, partitions, and stale replicas all complicate the picture, and none of them disappear. But the core stays true and worth holding onto: stronger reads and writes need more agreement, and more agreement costs latency and availability. The arithmetic just makes that trade visible.
Replication lag is a product metric
Once a feature leans on eventual consistency, replica lag stops being an infrastructure detail. It becomes part of how the product behaves, and so it deserves to be watched the way product behavior is watched.
A few metrics carry most of the signal:
| Metric | Why it matters |
|---|---|
| Replica apply lag | Shows how far followers are behind the leader |
| Projection lag | Shows how far read models are behind events |
| Queue age of oldest event | Shows whether async work is still inside its promise |
| Stale reads served | Shows how often users see old data by design |
| Read fallback to leader | Shows cost of read-your-writes paths |
| Conflict count | Shows when multi-writer assumptions are failing |
| Repair job changes | Shows how often convergence needs help |
If users can observe the lag, it should have an SLO, the same as any other promise:
profile projection p99 lag < 5 seconds
search indexing p95 lag < 30 seconds
analytics dashboard lag < 10 minutes
Without a lag target, "eventual" has no boundary, and a system with no boundary cannot tell you when it has drifted out of bounds.
Conflicts are design problems
Conflicts arise when two writers can update related state and no single authority decides between them. They feel like runtime accidents, but they are usually decided much earlier, in the shape of the data model.
So the easiest conflict strategy is the one that prevents the conflict from being possible:
- One owner for each entity.
- One partition key for ordered updates.
- One writer for a field when possible.
- Optimistic concurrency for user-edited documents.
Optimistic concurrency is often enough:
UPDATE documents
SET body = $1,
version = version + 1,
updated_at = now()
WHERE id = $2
AND version = $3;
If zero rows update, the client was editing an old version. The product can then ask them to reload, merge, or choose between versions, calmly, before anything is overwritten.
Last-write-wins keeps the most recent value and drops the rest. That is exactly right for fields where the latest setting is the whole intent:
display theme
sort preference
temporary draft title
And it is exactly wrong for fields where each write carries intent that must survive. Dropping a write here loses something real, so the conflict needs domain-specific handling:
account balance
inventory reservation
document body
permission grant
seat assignment
For counters and sets with multiple writers, reach for a known conflict-free approach or a database feature that already implements the merge semantics. Hand-rolling CRDTs is rarely where I would start; it tends to be the right tool only when the product genuinely needs offline multi-writer edits, and most products do not.
What I notice
A lot of confusion clears up once you separate three questions that consistency discussions tend to blur together:
- Can the system store the correct fact?
- Can every reader see that fact immediately?
- Can the product tolerate some readers seeing an older fact?
They are separate questions, and a healthy system often answers them differently. It can hold a strongly consistent source of truth and serve eventually consistent read models from it. That combination is not a compromise; it is the common, practical shape.
A user profile might be strongly written to users, then eventually projected out to search, feed headers, notifications, and caches. One fact, many read paths, each with its own promise.
The bugs tend to come from a single, forgivable lapse: forgetting which of those reads are eventual.
Make pending states explicit
Async systems live in the middle. Work is accepted but not yet done, and that in-between is a real state, not a gap to paper over. The kindest thing the product can do is name it.
Some examples:
| Operation | Better state model |
|---|---|
| Video upload | uploaded, processing, ready, failed |
| Search indexing | saved, indexing, searchable |
| Payment | authorized, captured, settled, failed |
| Import job | queued, running, completed, failed |
| Profile update | saved, visible_in_search_soon if search lag matters |
The rule is quiet but firm: the backend calls a projection complete only once it is complete.
When the write is accepted but the derived work is still in flight, the API response should say so plainly.
{
"status": "saved",
"search_status": "indexing",
"profile_version": 42
}
Precise state names quietly remove a whole class of trouble: fewer retry loops, fewer support tickets, fewer clients guessing wrong about work that simply was not finished yet.
Strong where the invariant lives
A split I keep coming back to, as a starting point rather than a rule:
| Data | Consistency level I would start with |
|---|---|
| Ledger entries | Strong transaction around double-entry invariant |
| Available inventory | Strong reservation or single-writer partition |
| Unique username | Strong uniqueness constraint |
| Permission checks | Strong or fail closed for sensitive actions |
| Idempotency keys | Strong insert/read in the command transaction |
| Profile display name | Strong source of truth, eventual projections |
| Feed item delivery | Eventual, with repair and replay |
| Search index | Eventual, with lag metric |
| View count | Eventual or approximate |
| Analytics dashboard | Eventual, often batched |
The search index can stay eventual alongside the profile table. The edit returns on its own, search catches up after, and almost no one is harmed by the gap. Coupling them strongly belongs to the rare product that truly needs search to update inside the edit response.
Inventory is the opposite case, and worth keeping strong when the product sells scarce items and every reservation must hold. Coordinated reservations are simply what keeps the last item from being sold twice.
Repair is part of eventual consistency
Eventual consistency is not complete without a repair path. The repair path is what keeps the word "eventual" honest when an event is dropped, and events do get dropped. It is the quiet safety net underneath the promise.
The common tools are unglamorous, which is a good sign:
- Outbox replay from a durable source.
- Periodic reconciliation from source of truth to projection.
- Dead-letter queues for poison events.
- Version checks on projections.
- Idempotent handlers that can run more than once.
Example reconciliation:
def repair_profile_projection(batch_size=500):
stale_profiles = db.find_profiles_newer_than_search(batch_size)
for profile in stale_profiles:
search.upsert_user(
user_id=profile.user_id,
display_name=profile.display_name,
source_version=profile.version,
)
A good repair job is boring, and it should stay that way. It compares the source version to the projection version and updates whatever is missing or old. Nothing clever.
There is one line worth remembering here: if a derived system cannot be repaired from the source of truth, then it is itself a source of truth, whether or not you meant it to be, and its consistency deserves to be treated with that seriousness.
Multi-region makes the tradeoff visible
Inside a single region, the round trips that strong consistency needs stay short. The cost is there, but small enough that many paths can comfortably afford it.
Across regions, those same round trips stretch over real distance, and the cost stops hiding.
A write that must coordinate between Tokyo, Virginia, and Frankfurt waits on the speed of light, on network variance, on failure handling. If every profile edit needs global agreement before it returns, the user pays that toll on every single edit, and they feel it.
A few common patterns:
| Pattern | Fit |
|---|---|
| Single write region | One place to reason about correctness, with added latency for distant writers |
| Regional reads with one write leader | Good for read-heavy products |
| Per-tenant or per-user home region | Keeps most writes near their owner |
| Active-active with conflict resolution | Useful when local writes must continue during partition |
| Global strong consistency | Reserve for narrow invariants that justify the latency |
A tight consistency boundary is what buys this freedom. Keep the coordinated work local and small, and the rest of the system is free to spread across regions without dragging that latency along.
What I think
When I have to do this for real, the sequence is roughly this:
- Write down the invariant.
- Decide which reads must observe it immediately.
- Put those reads on the coordinated path.
- Give every derived read model a lag budget.
- Return versions from writes.
- Use read-your-writes only for users who need it.
- Make conflict policy explicit.
- Measure lag and stale reads.
- Build repair from the source of truth.
If there is one underengineering move to keep, it is this: make only the invariant strong.
That single discipline keeps the expensive guarantee close to the data that actually needs it, and lets everything else breathe.
Tutorial checklist
For any consistency-sensitive feature, it helps to fill this out before writing code:
| Question | Example answer |
|---|---|
| Feature | Profile update |
| Source of truth | users table |
| Invariant | User profile version increases monotonically |
| Strong path | Update users and outbox in one transaction |
| Eventual paths | Search, feed headers, profile card cache |
| Read-your-writes | Writer reads leader or replica with version >= returned_version |
| Stale promise | Other users may see old display name for up to 5 seconds |
| Conflict policy | Last-write-wins for display name, versioned writes for bio |
| Lag metric | p99 profile projection lag |
| Failure behavior | Show saved profile immediately; search catches up later |
| Repair path | Reconcile projection version from users to search |
| Alert | p99 projection lag above 5 seconds for 10 minutes |
And then one closing question, which sorts most decisions on its own:
If a stale read happens here, is it annoying, misleading, or incorrect?
Annoying can often stay eventual.
Misleading needs product handling.
Incorrect needs coordination.
Three words, and most of the design falls into place behind them.
Summary
If you carry only a handful of things away from this, let it be these:
- Consistency is a read promise that each read path makes.
- Strong consistency is coordination, and coordination costs latency and availability.
- Eventual consistency should have a lag budget and repair path.
- Many systems use a strong source of truth with eventual read models.
- Read-your-writes can be targeted with versions and leader fallback.
- Quorums make read/write agreement explicit, and the operational complexity stays for you to handle.
- Conflict resolution should follow product semantics; last-write-wins fits fields whose latest value is their whole intent.
- Pending states should be named when derived work happens later.
- Measure replica lag, projection lag, stale reads, conflicts, and repair activity.
- Make the invariant strong, then let lower-risk projections lag intentionally.
Pop quiz
Interactive quiz
Consistency tradeoffs
A randomized review of strong consistency, eventual consistency, lag budgets, quorums, and conflict handling.