The five fixes for CQRS eventual consistency
What it is
Separating reads from writes means the read model lags the write model. The user writes, then reads, and sees their own change missing. That is the only genuinely hard problem CQRS introduces, and there are exactly five ways to solve it.
1. RETURN THE RESULT FROM THE COMMAND
The write returns what the read would have returned. No read
is needed, so there is nothing to be stale.
2. CLIENT-SIDE PROJECTION (optimistic UI)
The client already knows what it submitted. It renders the
expected state immediately and reconciles later.
3. VERSION TOKEN (read-your-writes)
The write returns a version. The client sends it back on the
next read. The read waits for, or is routed to, a projection
at or past that version.
4. ROUTE THAT USER'S READS TO THE WRITE MODEL
For a bounded window after their write, serve this user from
the authoritative side.
5. MAKE THE LAG EXPLICIT IN THE UI
Show that the change is processing. Design the workflow so
the wait is honest rather than hidden.
They are not alternatives so much as a menu, and most systems use three of them in different places.
What this is confused with: eventual consistency as a global property. The problem is almost always read-your-own-writes for one user, not global convergence. Other users seeing a 200ms-old view is nearly always fine; the author seeing their own edit missing is never fine, and conflating the two leads teams to make the whole system synchronous to fix one interaction.
Also confused: CQRS and asynchronous projections. CQRS is a separation of models; the projection can be updated in the same transaction (see the adoption ladder in CQRS: the adoption ladder). The lag is a consequence of the level you chose, not of the pattern.
The problem it solves
The failure is small, universal and destroys trust immediately.
User edits their profile name and clicks Save.
t+0ms command accepted, event written
t+3ms HTTP 200 returned
t+5ms browser navigates to the profile page
t+6ms read model queried
t+40ms projection catches up
At t+6ms the read model still has the old name.
The user sees their change did not happen, so they do it again.
Now there are two commands, and if the operation is not
idempotent there are two of whatever it created.
The double-submit consequence is the part that turns an annoyance into a data problem, and it is why "we will tell them to refresh" is not a design.
And the cost of the obvious fix is worse than the problem:
"Make the projection synchronous."
the write now blocks on N projection updates
write latency: 3ms -> 45ms
write availability now depends on every projection's
availability: 0.999^4 = 0.996
a projection rebuild, which was previously a background
operation, is now an outage
and you have given up the thing CQRS was for
That is a legitimate choice at small scale and it is
Level 1 on the adoption ladder, not a fix for Level 4.
Mechanics
Fix 1: return the result from the command
The cheapest fix, and the most under-used, because it requires only that the command handler return something.
// The write side already computed the new state. Return it.
public OrderView placeOrder(PlaceOrder cmd) {
Order order = Order.place(cmd); // domain logic
repository.save(order); // authoritative write
events.publish(order.uncommittedEvents());
// Project the SAME aggregate into the read shape, in memory,
// and hand it back. The client renders this, so no read
// query happens at all.
return OrderView.from(order);
}
Applies when: the command's result IS what the user will look
at next. Create, edit-and-return, single-entity workflows.
Does not apply when: the next view aggregates across entities
the command did not touch (a list, a dashboard, a search
result), because the write side does not have that data.
Cost: the write side must know the read shape, which is a small
coupling and is usually worth it.
The reason this is under-used is a REST habit: POST /orders returning 201 with a Location
header and an empty body forces a follow-up GET that is guaranteed to race the projection. Return
the representation.
Fix 2: client-side projection (optimistic UI)
// The client knows what it asked for, so it can render the
// expected result immediately and correct itself later.
async function renameProfile(newName) {
const previous = store.profile.name
store.profile.name = newName // optimistic
store.profile.pending = true
try {
const result = await api.rename({ name: newName, idempotencyKey: key() })
store.profile = result // authoritative, from fix 1
store.profile.pending = false
} catch (err) {
store.profile.name = previous // rollback
store.profile.pending = false
notify('Rename failed: ' + err.message)
}
}
Applies when: the client can compute the expected result, and
the operation usually succeeds.
Does not apply when: the server assigns something the client
cannot predict (an id, a computed price, a rank, an approval
outcome), or the failure rate is high enough that rollbacks
are common and confusing.
Cost: two code paths for the same state, and the rollback path
is the one nobody tests. It is also the only fix that is
wrong in a specific way: it shows the user something that
is not true yet.
Pair it with an idempotency key, because an optimistic UI plus a retry is exactly the double-submit case.
Fix 3: version tokens, the general solution
This is the fix that composes, works across services, and does not require the client to guess anything.
Write:
command handler returns { id, version: 4711 }
(a monotonic per-aggregate version, a global sequence, a
Postgres LSN, or a Kafka offset)
Read:
client sends ?minVersion=4711 or a Consistency-Token header
read side either:
(a) serves from a projection whose watermark >= 4711
(b) waits, briefly and boundedly, for the projection to
reach 4711
(c) falls back to the write model if the wait expires
def get_order(order_id: str, min_version: int | None, deadline_ms: int = 150):
"""Read-your-writes without making the projection synchronous.
The token is opaque to the client: it stores what the write
returned and gives it back. It never interprets it.
"""
if min_version is None:
return read_model.get(order_id) # no guarantee needed
deadline = monotonic_ms() + deadline_ms
while monotonic_ms() < deadline:
view = read_model.get(order_id)
if view is not None and view.version >= min_version:
return view
sleep_ms(10)
# Bounded wait expired. Two honest options, and this is a
# product decision rather than a technical one:
# - fall back to the write model (correct, more expensive)
# - return the stale view flagged as stale, and let the UI
# say "updating"
return write_model.get_as_view(order_id, stale=True)
Three properties make this the general answer:
1. IT IS PER-REQUEST. Only reads that need the guarantee pay
for it. A dashboard poll sends no token and is served from
the fastest replica.
2. IT COMPOSES. The token travels through a gateway, an API
client, a mobile app's local storage, or a cookie, and any
service holding a projection can honour it.
3. IT DEGRADES HONESTLY. The wait is bounded, and expiry is a
decision you made in advance rather than an unbounded stall.
The same mechanism is what a database gives you for replica lag, and the vocabulary is worth knowing: Postgres exposes an LSN you can wait on, MySQL has GTIDs, DynamoDB has strongly consistent reads as the coarse version, and MongoDB's causal-consistency sessions implement exactly this with cluster time.
Where the token lives matters:
in a cookie simplest for a browser, survives
navigation, and it is per-device
in the client's memory fine for an SPA, lost on refresh
in the session store works across devices, costs a lookup
in the request context for service-to-service, propagate it
like a trace id
Fix 4: route the user's reads to the write model
A blunt instrument that works, and its cost is concentrated rather than spread.
After a user writes, mark them "recently wrote" for N seconds
(a cookie, or an entry in a fast store keyed by user id).
While the mark is present, their reads bypass the read model.
N should be several times the p99 projection lag, not the
p50. If lag p99 is 400ms, N = 2s is defensible; N = 30s is
someone guessing.
Applies when: writes are rare relative to reads, so only a
small fraction of traffic takes the expensive path, and the
write model can serve the read shape at all.
Does not apply when: the read model exists precisely because
the write model cannot serve these queries (a search index, a
denormalised aggregate, a graph projection). Then there is
nothing to route to.
Cost: a portion of read traffic hits the authoritative store,
which is the load you built the read model to avoid. Bound it
and measure it.
This is the "sticky to primary for N seconds" pattern from database replication, applied one layer up, and it has the same failure mode: if N is set by guesswork rather than from measured p99 lag, it is either ineffective or expensive.
Fix 5: make the lag explicit
The fix people dismiss as "not a real fix", and it is often the correct one.
Two distinct techniques:
SHOW THE PENDING STATE
"Saving..." / a pending badge / a disabled control until the
projection confirms. Honest, and it removes the double-submit
because the control is not clickable.
CHANGE THE WORKFLOW SO THE READ DOES NOT FOLLOW THE WRITE
After submitting an expense report, do not navigate to the
list of expense reports. Show a confirmation page built from
the command's own result (fix 1), with the list one click
away, by which time the projection has caught up.
The workflow change is the highest-leverage version of any fix on this page, because it eliminates the race rather than winning it, and it usually costs one conversation with a designer.
Applies when: the operation is genuinely asynchronous in the
user's mental model anyway. Submitting a report, placing an
order, uploading a file, requesting a refund. Users accept
"we are processing this" for things that sound like work.
Does not apply when: the operation feels instantaneous.
Toggling a setting, renaming something, liking a post.
"Processing your like" is absurd, and there fixes 1 and 2 are
the right answer.
Choosing, as a decision procedure
Does the command's result contain what the user will look at
next?
YES -> FIX 1. Return it. Stop.
NO -> continue.
Can the client compute the expected result, and does the
operation usually succeed?
YES -> FIX 2, with an idempotency key. Often combined with 1.
NO -> continue.
Is the next view a query the write model can serve?
YES, and writes are rare -> FIX 4, with N derived from
measured p99 lag.
NO -> continue.
Does the user's mental model already accept "processing"?
YES -> FIX 5, and change the workflow so the read does not
immediately follow the write.
NO -> FIX 3. The version token is the general answer and the
one that composes.
Fix 3 is the fallback for everything, and fixes 1, 2 and 5 are cheaper where they apply. A mature system uses several: the command returns its result, the UI renders optimistically, and the version token covers the cases neither handles.
A worked example: an expense system with four different races
An expense management product on Level 3 CQRS (async projections from an event stream, p50 lag 35ms, p99 lag 420ms, p99.9 lag 2.1s during rebuilds). Four separate user-visible bugs, all "eventual consistency", each needing a different fix.
Bug 1: renaming a category showed the old name.
POST /categories/{id} -> 204 No Content
then GET /categories -> old name, ~30% of the time
Diagnosis: the write returned nothing, forcing a read that
raced a 35ms projection.
FIX 1 applied: the command returns the updated CategoryView.
The client renders it directly. No read.
Result: 0% occurrence. Change was 11 lines.
The 11-line fix eliminating a 30 percent failure rate is the argument for checking fix 1 first, and the reason it had not been done was a team convention that mutations return 204.
Bug 2: submitting an expense, then landing on a list that did not contain it.
The list is a cross-entity projection the write model cannot
produce, so fix 1 does not apply.
Considered FIX 4 (route to write model): rejected, because the
list is a denormalised join across expenses, categories,
approvers and policy state. The write model genuinely cannot
serve it.
Considered FIX 3 (version token): would work. Held in reserve.
APPLIED FIX 5, the workflow version: after submission the user
now lands on a confirmation page built from the command's own
result, with "View all expenses" as a link. Median time before
the user clicks it: 4.2 seconds, against a p99 projection lag
of 420ms.
Result: the race was eliminated rather than won. Zero added
latency, zero added complexity, one conversation with the
designer.
Changing where the user lands beat every technical fix considered, and it is the outcome the technical framing of the problem hides.
Bug 3: an approver's dashboard, after approving, still showed the item as pending.
The approver approves item A, and the dashboard is a filtered,
aggregated, sorted view across thousands of items with counts
per state. Fix 1 cannot produce it, fix 5 is wrong (approving
feels instantaneous), fix 4 cannot work (the write model has no
such aggregate).
FIX 3 applied.
POST /approvals -> 200 { itemId, version: 88231 }
the SPA stores the version and sends it as a
Consistency-Token header on the next dashboard read
the read API waits up to 150ms for the projection watermark
to reach 88231, then serves
Measured over a month:
reads carrying a token 6.1% of dashboard reads
of those, served immediately 82% (projection already
ahead)
waited, then served 17.7% (median wait 24ms)
hit the 150ms deadline 0.3% -> served stale with a
flag, and the UI shows
"updating"
Added p99 latency on dashboard reads overall: 4ms, because
94% of reads carry no token and pay nothing.
Only 6.1 percent of reads needed the guarantee and only those paid for it, which is the property that makes version tokens affordable where a synchronous projection is not.
Bug 4: a mobile client, offline for a while, showed data from before its own queued writes.
The mobile app queues writes offline and replays them on
reconnect. On reconnect it also refreshes its views, and the
refresh raced its own replayed writes.
FIX 2 + FIX 3 combined:
- the local store applies each queued command optimistically,
so the UI is correct from the user's perspective the whole
time
- each replayed command returns a version; the client keeps
the HIGHEST version it has seen and sends it on every
subsequent read
- reads are reconciled against the local optimistic state:
server data at or past the client's high-water version
replaces it; anything behind is ignored
The high-water-mark rule is what makes it correct: without it,
a read that returns a projection at version 88100 would
overwrite optimistic state derived from a command at 88231.
The high-water mark is the detail that turns optimistic UI from a trick into a protocol, and it is the thing most implementations get wrong.
What was measured after all four:
before after
"my change didn't save" support
tickets per month 41 2
duplicate submissions per month 18 0 (idempotency
keys, added
with fix 2)
dashboard read p99 61ms 65ms
write p99 14ms 14ms (unchanged:
no
projection
was made
synchronous)
projections made synchronous 0 0
Write latency is unchanged and no projection became synchronous, which is the point: all four problems were solved without giving up the property CQRS was adopted for.
One thing that was tried and reverted:
An early attempt used FIX 4 globally: any user who had written
in the last 30 seconds had ALL their reads routed to the write
model.
At peak, 23% of read traffic was routed to the primary, whose
load rose 40% and whose p99 doubled. The 30 seconds had been
chosen because it "felt safe"; measured p99 projection lag was
420ms.
Reverted. Where fix 4 was kept (a single small entity view), N
was set to 2 seconds, derived as ~5x the p99 lag, and routed
traffic fell to 0.9%.
The lesson recorded: fix 4's cost is proportional to N and to
the write rate, and both are measurable, so N should never be
a guess.
Production evidence
MongoDB's causal consistency sessions implement the version-token mechanism directly: a session
carries an operation time, and a read with afterClusterTime waits for a secondary to reach it. It is
the clearest managed implementation of fix 3, including the bounded-wait behaviour.
PostgreSQL exposes the WAL LSN (pg_current_wal_lsn) and lets an application wait for a replica to
reach it, which is the same pattern one layer down; MySQL's GTID-based WAIT_FOR_EXECUTED_GTID_SET
serves the same purpose.
DynamoDB's strongly consistent reads are the coarse-grained version: a per-request flag that costs more and reads from the leader, which is fix 4 expressed as an API parameter.
"Sticky to primary for N seconds" after a write is standard practice with read replicas and is documented in the operational guidance of most managed relational databases, along with the caveat that N must exceed observed replication lag.
Optimistic UI with reconciliation is standard in modern client frameworks: TanStack Query's
onMutate/onError rollback and Apollo's optimistic responses both implement fix 2 with an explicit
rollback path, which is the acknowledgement that the rollback is the part that needs framework support.
Returning the created representation rather than 201 with an empty body is explicitly permitted by
HTTP semantics and is recommended in most modern API design guidance precisely because the follow-up
GET is both a round trip and a race.
The CQRS literature (Greg Young's original writing, Udi Dahan's articles on the topic, and Microsoft's CQRS Journey guide) treats the read-your-own-writes problem as the pattern's principal practical objection, and the fixes enumerated there are the same five.
The debate
Should you just make the projection synchronous? At low scale with one or two projections, yes, and that is a legitimate place to sit on the adoption ladder. At Level 4 it undoes the pattern: write latency absorbs every projection, write availability becomes the product of every projection's availability, and a projection rebuild becomes an outage. The position: solve the interaction, not the architecture.
Is optimistic UI dishonest? It shows the user something that is not yet true, which is a real objection, and it is acceptable when the operation almost always succeeds and the rollback is visible and explained. It is wrong when the server decides something the client cannot predict, an approval, a price, a rank, because then the optimistic state is not a prediction, it is a guess.
Are version tokens over-engineering? For one interaction, yes; fix 1 or fix 5 is cheaper. For a system with many read models and clients across web, mobile and partner APIs, the token is the only fix that composes, and its cost is bounded because only the reads that need the guarantee send one: in the worked example 6.1 percent of dashboard reads, adding 4ms to the overall p99.
Should the token be opaque? Yes. The moment a client interprets it, you cannot change its representation, and you will want to, from a per-aggregate version to a global sequence to an LSN. Return it, store it, send it back, never parse it.
Is fix 5 a cop-out? It is frequently the best answer and it is rejected on aesthetic grounds. A workflow change that means the read does not immediately follow the write eliminates the race rather than winning it, at zero latency cost and zero complexity, and in the worked example it beat every technical option considered. The honest limit is that it only works where the user's mental model already accepts processing, which rules out toggles, likes and renames.
How long should a bounded wait be? Long enough to cover the p99 projection lag and short enough that expiry is rare and cheap. A wait that never expires is a synchronous projection with extra steps, and what happens on expiry, falling back to the write model or serving stale with a flag, is a product decision that must be made explicitly rather than defaulting to a stall.
Follow-up Q&A
"What problem does CQRS actually introduce, and what does it not?"
Read-your-own-writes for a single user, not global convergence. Another user seeing a 200-millisecond-old view is nearly always fine; the person who just made a change seeing it missing is never fine, and it also causes them to submit again, which turns an annoyance into duplicate data unless the operation is idempotent. Conflating the two leads teams to make the whole system synchronous to fix one interaction, which gives up exactly the property they adopted the pattern for.
"What are the five fixes?"
Return the result from the command, so no read happens. Client-side optimistic projection, since the client knows what it submitted. A version token: the write returns a version, the client sends it back, and the read waits briefly for a projection at or past it. Routing that user's reads to the write model for a bounded window. And making the lag explicit, either with a pending state or by changing the workflow so the read does not immediately follow the write. They are a menu rather than alternatives, and a mature system uses three of them in different places.
"Which one do you reach for first?"
Whether the command's result contains what the user will look at next. If it does, return it and stop: that fix is usually a handful of lines and it eliminates the race entirely. In one system a 30 percent failure rate on a rename disappeared with an eleven-line change, and the only reason it had not been done was a team convention that mutations return 204 No Content, which forces a follow-up GET guaranteed to race the projection.
"How does the version token work, and why is it affordable?"
The write returns an opaque version, the client stores it and sends it on the next read, and the read side either serves from a projection already at or past that version or waits, briefly and boundedly, for it. It is affordable because it is per-request: only reads that need the guarantee send a token, so in one system 6.1 percent of dashboard reads carried one and the overall p99 rose by 4 milliseconds. Compare that to a synchronous projection, which taxes every write and makes write availability the product of every projection's availability. It also composes across services, gateways and clients, because the token propagates like a trace id.
"What should happen when the bounded wait expires?"
A decision made in advance, not a stall. Either fall back to the write model, which is correct and more expensive, or serve the stale view flagged as stale so the UI can say "updating". A wait that never expires is a synchronous projection with extra steps. Set the deadline from measured p99 projection lag, and monitor the expiry rate: in one system 0.3 percent of token-carrying reads hit the 150-millisecond deadline, which is a rate you can serve stale on without anyone noticing.
"What is the failure mode of optimistic UI on a mobile client?"
A refresh overwriting optimistic state derived from a newer command. The fix is a high-water mark: the client keeps the highest version it has seen from its own commands and ignores any server response behind it. Without that rule, a read returning a projection at version 88100 clobbers state derived from a command at 88231, and the user watches their change disappear after it had already appeared. That rule is what turns optimistic UI from a trick into a protocol.
"When is changing the workflow the right answer?"
When the user's mental model already accepts that something is being processed: submitting a report, placing an order, uploading a file, requesting a refund. Landing on a confirmation page built from the command's own result, with the list one click away, eliminates the race instead of winning it, at zero latency and zero complexity. In one case the median time before the user clicked through was 4.2 seconds against a p99 projection lag of 420 milliseconds. It is wrong where the operation feels instantaneous, because "processing your like" is absurd, and there the answer is fix 1 or fix 2.
Common misconceptions
"Eventual consistency means users see stale data." Other users seeing slightly stale data is fine. The problem is one user not seeing their own write, and it is a different problem with different fixes.
"Make the projection synchronous." That taxes every write, makes write availability the product of every projection's availability, and turns a rebuild into an outage. Solve the interaction instead.
"Return 204 from mutations." It forces a follow-up read that is guaranteed to race the projection. Return the representation.
"Optimistic UI is always good UX." It shows something untrue, and it is wrong whenever the server decides something the client cannot predict.
"Sticky-to-primary for 30 seconds is safe." Its cost is proportional to the window and the write rate. In one system 30 seconds routed 23 percent of reads to the primary and doubled its p99; the measured p99 lag was 420 milliseconds and 2 seconds was sufficient.
"Telling the user it is processing is a cop-out." It frequently beats every technical option, costs nothing, and removes the double-submit because the control is disabled.
Interview delivery note
Say this verbatim: "The problem CQRS introduces is read-your-own-writes for one user, not global convergence, and the fix I reach for first is returning the result from the command so there is no read to be stale. The general fallback is a version token: the write returns a version, the client sends it back, and only the reads that need the guarantee pay for it." It scopes the problem correctly and gives both the cheap fix and the composable one.
The senior-versus-staff separator is refusing to make the projection synchronous and saying why in terms of availability. A senior engineer solves the staleness. A staff engineer points out that a synchronous projection makes write availability the product of every projection's availability, turns a routine projection rebuild into a write outage, and taxes every write to fix one interaction, then solves the interaction instead and reports that write p99 was unchanged at 14 milliseconds.
The second signal is deriving the sticky-window from measured lag. Saying "we started with sticky-to-primary for 30 seconds because it felt safe, which routed 23 percent of reads to the primary and doubled its p99, then set it to 2 seconds as five times the measured p99 projection lag and routed traffic fell to 0.9 percent" shows you price your own mitigations and that you know this parameter has a measurable correct value rather than a comfortable one.
Further reading
- Microsoft's CQRS Journey guide, particularly its treatment of the read-your-own-writes objection.
- MongoDB's causal consistency documentation, for a managed implementation of the version-token wait.
- PostgreSQL's WAL LSN functions and MySQL's
WAIT_FOR_EXECUTED_GTID_SET, for the same mechanism at the database layer. - TanStack Query's optimistic-update documentation, for the rollback path that fix 2 requires.
- The CQRS adoption ladder page, which decides how much lag you have in the first place.