Filter concepts by levelShowing all levels.

System Design · Section 34

Eventual Consistency

Level
intermediate
Read
20 min
Concepts
3

Distributed components — replicas, queue consumers, independent caches — temporarily disagree not because anything is broken, but because propagation across a network takes real, measurable time; the defining guarantee of "eventual" consistency is that the system provably converges once writes stop, with the actual staleness window being a trackable, component-specific quantity rather than a vague unknowable delay. Choosing between eventual and strong consistency is a per-workflow decision, not a system-wide one — the same underlying data can be read with eventual consistency for informational display and strong consistency for the specific operation that enforces a real invariant like a balance check or a uniqueness constraint. Finally, when a workflow is genuinely asynchronous, the honest fix is an explicit, user-visible status (pending, processing, complete, failed) rather than hiding the delay or letting a caller infer completion from elapsed time alone.

What is true here

  1. Replicas, queues and caches disagree temporarily because propagation takes real time — a structural fact, not a bug, and the staleness window is measurable per component.
  2. "Eventually consistent" guarantees convergence once writes stop; it says nothing about speed unless that speed is separately measured and tracked.
  3. Consistency is chosen per workflow based on the real cost of acting on stale data — the same data can need different consistency for different reads of it.
  4. A genuinely asynchronous workflow needs an explicit, visible status including real failure states — not a hidden delay or a timing-based guess.

What you will be able to do

  • Explain why distributed components can correctly disagree at any given moment without anything being wrong
  • Choose eventual vs strong consistency for a specific workflow based on the real cost of staleness
  • Design an explicit status/state machine for a genuinely asynchronous operation instead of hiding its delay

Why disagreement happens, and choosing per workflow

The structural reason distributed components temporarily disagree, and how to decide which workflows can tolerate that and which cannot.

Why replicas, queues and distributed services temporarily disagree

coreintermediate

Eventual consistency is the property that, given enough time with no new writes, every copy of a piece of data across a distributed system will converge to the same value — but at any given moment before that, different parts of the system can legitimately disagree about the current state. This disagreement is not a bug; it is the direct, structural consequence of propagation taking real time across a network. A replica has not yet applied the primary's latest write (see replication lag), a queue consumer has not yet processed the latest message, and two services that each cache the same entity independently can each be looking at a snapshot from a different moment.

Think of it as

It is like several people reading the same rapidly-updating scoreboard from different distances and different delays. Someone standing right at the scoreboard sees a point scored the instant it happens. Someone watching a live TV broadcast sees it a few seconds later, after the broadcast delay. Someone checking a sports app that refreshes every 30 seconds sees it even later still. All three are looking at the "same" scoreboard and are all telling the truth about what they see — they simply have not all received the update yet, and each will eventually agree once enough time passes without new points being scored.

What we're doing: Show three different components of one system legitimately disagreeing about a single user's display name at the same real-world instant.

temporary-disagreement.txttext
A user changes their display name from "Alex" to
"Alexandra" at 10:00:00.000.

10:00:00.000  Primary database: write committed.
              "Alexandra" is now the true value.
10:00:00.050  A read replica, still applying the
              primary's change stream, still shows
              "Alex" for another ~50ms.
10:00:00.200  A "profile updated" event was published
              to a queue at commit time; the search-
              index consumer hasn't processed it yet
              -- search results still show "Alex".
10:00:05.000  A separate recommendation service's
              cache of this user's profile, refreshed
              every 5 minutes, still shows "Alex" for
              up to 5 minutes after the change.

By 10:05:00, all three have converged on
"Alexandra" -- eventual consistency's guarantee
held, but "eventual" spanned nearly 5 minutes for
the slowest of the three components.
4
This is the one authoritative value at this instant — everything after this line is a copy that has not yet caught up.
17
This is the guarantee actually being kept — every component DID converge, it just took very different amounts of time to get there.

Why this works: This is the concrete, quantified version of "eventually consistent" — three real components, three genuinely different staleness windows, all correctly converging by the end, which is exactly what distinguishes eventual consistency from a system that simply never agrees.

Treating "eventually consistent" as a vague promise instead of measuring the actual staleness window

Wrong

text
"Our system is eventually consistent, so it's
fine if things are stale for a while — we don't
need to know exactly how long."

Better

text
"Our system is eventually consistent with a
measured p99 staleness window of ~2 seconds for
replicas and ~30 seconds for the search index —
we track both explicitly, because 'eventually'
without a number is not something we could
actually design a user experience around."

What you see: A feature built on the assumption that "eventual" means "basically instant" breaks in production the first time real load pushes the actual staleness window from milliseconds to multiple seconds or minutes — because nobody had measured what "eventual" really meant for this specific system under real conditions, only assumed it was short.

Why: "Eventually consistent" is a guarantee about convergence, not a guarantee about speed — the actual staleness window is a measurable, monitorable quantity that varies by component and load, and a design that treats it as an unknowable vague delay cannot make an informed decision about which workflows can tolerate it and which cannot.

One write, three different convergence times
Primary
Read replica
Search index
Recommendation cache
  1. 1. commits "Alexandra"t=0.000s
  2. 2. still shows "Alex"t=0.050s
  3. 3. still shows "Alex"t=0.200s
  4. 4. still shows "Alex"up to t=5:00
  1. Primary → Primary: commits "Alexandra" (t=0.000s)
  2. Primary → Read replica: still shows "Alex" (t=0.050s)
  3. Primary → Search index: still shows "Alex" (t=0.200s)
  4. Primary → Recommendation cache: still shows "Alex" (up to t=5:00)

Three sources of temporary disagreement, and the mechanism behind each

Three sources of temporary disagreement, and the mechanism behind each
SourceMechanismTypical staleness window
Replica lagAsynchronous replication applies writes with a delayMilliseconds under normal load; seconds+ under write bursts
Queue/message delayA consumer has not yet received or processed a published messageDepends on consumer throughput and backlog depth
Independent cachesEach cache refreshes on its own schedule, unaware of the othersBounded by each cache's own TTL or invalidation trigger

Remember: Distributed components disagree temporarily because propagation — replication, queue processing, cache refresh — takes real time, not because anything is broken. "Eventual" means the system provably converges once writes stop; the actual staleness window is a measurable, component-specific quantity worth tracking explicitly, not a vague unknowable delay.

See also: choosing consistency per workflow · explicit status for async completion · replication lag

Choosing eventual vs strong consistency per workflow

coreintermediate

Consistency is not a single system-wide setting to pick once — different workflows within the same system tolerate staleness very differently, and the right choice is made per workflow, not globally. A social media "like count" being briefly stale is invisible to users and costs nothing real. An account balance being briefly stale can mean a user double-spends money that was never really there. The test is: what is the actual, concrete cost if this specific read is a few seconds (or minutes) out of date — and does that cost matter enough to pay strong consistency's real price (more coordination, more latency, less availability during a partition) for this specific piece of data.

Think of it as

Think of the difference between a store's "items in stock" display versus its cash register. If the stock display briefly overstates inventory by one unit because the last sale has not synced yet, the worst outcome is a slightly annoying "sorry, that just sold out" message — a minor, recoverable inconvenience. If the cash register briefly forgets a sale it already processed and lets two customers both walk out with what the system thinks is the last unit, that is a real, unrecoverable business problem. The store does not need the same freshness guarantee on both systems — it needs the register to be right immediately, and can tolerate the display being a little behind.

What we're doing: Show the same account-balance data needing two different consistency levels depending on which workflow is reading it.

same-data-different-needs.txttext
Dashboard display (eventual consistency, OK):
  User opens their account page. Balance is read
  from a read replica that may be a few hundred
  milliseconds stale. If it's showing $500.03 when
  the true value is now $499.98 because a payment
  just cleared, the user sees a slightly outdated
  number for a moment -- annoying at worst, and it
  self-corrects on the next page refresh.

Withdrawal request (strong consistency, REQUIRED):
  Same user clicks "withdraw $500." The system MUST
  read the current, authoritative balance -- not a
  replica that might still say $500.03 when the real
  balance is actually $499.98 because of a payment
  that already cleared. Acting on the stale value
  here would let the withdrawal succeed when it
  should have been rejected -- a real, unrecoverable
  overdraft, not a cosmetic staleness issue.
7
This is the entire cost of getting it wrong for the dashboard case — a moment of visual staleness, self-correcting, no real consequence.
17
This is the entire cost of getting it wrong for the withdrawal case — real money, an irreversible mistake, not self-correcting on its own.

Why this works: The exact same underlying number — the account balance — genuinely needs two different consistency guarantees depending on what the read is being used FOR, which is the concrete argument against treating consistency as one system-wide setting rather than a per-workflow decision.

Making an entire data store strongly consistent because ONE workflow reading it needs strong consistency

Wrong

text
"The account balance needs strong consistency
for withdrawals, so we'll make every read of it
— including the dashboard display — hit the
primary database directly, strongly consistent,
everywhere."

Better

text
"Withdrawal checks read from the primary,
strongly consistent. Dashboard displays and other
informational reads can read from a replica —
eventual consistency is genuinely fine there, and
routing them to a replica keeps that read traffic
off the primary and reduces its load, which
indirectly helps the withdrawal path stay fast too."

What you see: A service that needed strong consistency for one narrow, critical operation ends up routing ALL of its read traffic (including high-volume, purely informational reads) through the same strongly-consistent, higher-latency, lower-availability path — paying strong consistency's real cost for reads that never actually needed the guarantee.

Why: Strong consistency is a property of a specific read (or the store backing it), not an all-or-nothing property of an entire data type — applying it uniformly because one narrow use case needs it wastes the real cost (latency, primary load, reduced partition tolerance) on every other read of the same data that would have been perfectly correct with eventual consistency.

Same balance, two different consistency needs

Dashboard display

  • +Reads from a replica, may be stale
  • +Worst case: a slightly outdated number
  • +Eventual consistency is fine

Withdrawal request

  • Must read the current, authoritative balance
  • Worst case: a real, unrecoverable overdraft
  • Strong consistency is required
  • Dashboard display
    • Reads from a replica, may be stale
    • Worst case: a slightly outdated number
    • Eventual consistency is fine
  • Withdrawal request
    • Must read the current, authoritative balance
    • Worst case: a real, unrecoverable overdraft
    • Strong consistency is required

Matching workflows to the right consistency level

Matching workflows to the right consistency level
WorkflowConsistency needWhy
Like/view counter displayEventualBeing briefly off by a few counts costs nothing and self-corrects
Search result freshnessEventualA newly-created item missing from search for a few seconds is a minor UX gap, not a correctness bug
Account balance display (informational)EventualShown for awareness; the actual constraint is enforced at withdrawal time, not display time
Withdrawal / balance check at transaction timeStrongActing on a stale balance can let a withdrawal succeed that should have been rejected
Inventory decrement at checkoutStrongA stale stock count can oversell a limited item
Unique username/email registrationStrongTwo concurrent signups both seeing "available" can both succeed, violating uniqueness

Remember: Consistency is chosen per workflow, not system-wide — the same data can be read with eventual consistency for informational display and strong consistency for the operation that actually enforces a constraint. Ask what the real cost of staleness is for THIS specific read before defaulting either way.

See also: why components disagree · explicit status for async completion · idempotent consumer design

Advertisement

Modeling asynchronous completion honestly

Using an explicit, visible status instead of hiding a genuine delay or inferring completion from elapsed time.

Using explicit states and user-visible status for async completion

coreintermediate

When a workflow is genuinely asynchronous — its final result is not available the instant the user takes an action — the right fix is not to hide that fact, but to model it explicitly with a status the user (or calling system) can actually see and act on. Instead of pretending an operation is instantly "done," an explicit state machine (e.g. pending → processing → complete, or pending → failed) tells the truth about where the operation currently stands, and gives the UI or caller something concrete to poll, subscribe to, or display, rather than silently showing stale or misleading information as if it were final.

Think of it as

Think of a package delivery tracking page versus a vending machine. A vending machine gives you the item the instant you pay — there is no "processing" state, because the operation really is synchronous and instant. A package delivery is genuinely asynchronous — it takes days, and a good tracking page does not pretend otherwise by just showing a blank "your order" until it arrives. It shows explicit, honest states: order placed, preparing for shipment, in transit, out for delivery, delivered — each one telling you exactly where things stand right now, so you are never left guessing whether something silently failed or is still legitimately in progress.

json
{
  "orderId": "ord_123",
  "status": "processing",
  "statusHistory": [
    { "status": "pending", "at": "2026-08-22T10:00:00Z" },
    { "status": "processing", "at": "2026-08-22T10:00:02Z" }
  ]
}

What we're doing: Show a video-processing workflow using explicit status instead of a caller guessing based on elapsed time.

explicit-status-workflow.txttext
User uploads a video. Processing (transcoding,
thumbnail generation) takes anywhere from 10
seconds to 10 minutes depending on file size.

WITHOUT explicit status:
  Client waits 30 seconds, then just tries to
  play the video. If it's not ready yet, the
  client has no way to distinguish "still
  processing, try again shortly" from "processing
  failed, will never be ready" -- both just look
  like "video not available."

WITH explicit status:
  video.status: "uploaded" -> "processing" ->
                "ready" | "failed"
  Client subscribes to status changes (or polls
  the status field specifically, not the video
  itself). At every point, the client knows
  EXACTLY what's happening:
    "processing" -> show a progress indicator
    "ready"      -> show the video player
    "failed"     -> show a retry/error option
  No guessing based on elapsed time anywhere.
10
This is the core problem — "not available" is genuinely ambiguous between three very different real situations.
20
Each explicit state maps to one, unambiguous UI response — the client never has to infer anything from timing alone.

Why this works: This is the concrete difference explicit status makes — the same underlying asynchronous reality (processing takes variable time) produces a UI that can respond correctly to what is actually happening, instead of one that has to guess based on how long it has been waiting.

Using elapsed time as a proxy for completion status instead of an explicit state

Wrong

text
// client-side guess based on timing alone
if (secondsSinceUpload > 60) {
    showError("Something went wrong");
} else {
    showSpinner();
}
// no actual signal from the server about
// what's really happening

Better

text
// client reads the actual server-reported state
switch (video.status) {
  case "processing": showSpinner(); break;
  case "ready": showPlayer(); break;
  case "failed": showError(); break;
}
// timing plays no role — the state IS the answer

What you see: Users see an incorrect "something went wrong" error for a video that was genuinely still processing (just slower than the guessed 60-second threshold), or conversely see an endless spinner for a video whose processing genuinely failed, because the client was inferring status from elapsed time instead of reading an actual reported state.

Why: Elapsed time is a poor proxy for completion status because real processing time varies (file size, current system load, retries) — a fixed timing threshold will always misclassify some fraction of genuinely-still-working cases as failed, and some fraction of genuinely-failed cases as still working, in a way an explicit status field simply does not.

Video processing: explicit states, not a timing guess
transcodingstartssucceedsfails

Uploaded

start

Processing

Ready

end

Failed

end

  • Uploaded (start)
    • → Processing when transcoding starts
  • Processing
    • → Ready when succeeds
    • → Failed when fails
  • Ready (end)
  • Failed (end)

Implicit assumption vs explicit status for the same async workflow

Implicit assumption vs explicit status for the same async workflow
PropertyNo explicit statusExplicit status field
Caller can tell "still working" from "failed"No — both look like "not done yet"Yes — distinct states
UI can show meaningful progressNo — has to guess or show nothingYes — renders the actual current state
A caller polling too early getsAn ambiguous or misleading resultA clear "pending" or "processing" answer
Debugging a stuck workflowHard — no record of where it got stuckEasy — the last recorded state shows exactly where

Remember: When completion is genuinely asynchronous, model it with an explicit, user-visible status (including real failure/cancellation states, not just the happy path) instead of hiding the delay or letting a caller infer status from elapsed time — the status itself is the honest signal, timing alone is not.

See also: why components disagree · choosing consistency per workflow · sync vs async messaging

Advertisement