The question this answers
I need a query the write model cannot serve efficiently. What does building a read model actually cost?
A materialized view guarantees that, given the events it has processed, its contents are a correct function of those events. It guarantees nothing about having processed all of them: the view is consistent with a *prefix* of the event stream, and the distance between that prefix and the present is the lag.
Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.
The projection knows which events it has applied — usually as an offset or a position. It does not know whether more events exist upstream, and it certainly cannot know whether a reader needs a value it has not yet processed. A reader querying the view knows only the values it got back; unless the view reports its position, the reader cannot tell whether it is looking at data from one second or one day ago. Publishing that position is what turns an invisible property into a usable one.
A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.
Events, projection, read model
The structure has three parts and one property. Events describe what happened, in order, from the source of truth. A projection is a function that folds those events into a shape: for each event, update the read model. The read model is that shape — a table, a document, an index — built for one query pattern and answering it in a single lookup.
The property is lag. Between an event being committed at the source and being visible in the view there is a delay: propagation, queueing, processing, indexing. Under normal load it may be milliseconds. Under backlog, during a rebuild, or after a consumer outage, it can be hours. The lag is not an implementation detail — it is the interface, because every consumer of the view is implicitly making an assumption about it, and unmeasured assumptions become incidents.
Architecture owns the patterns this composes: cqrs for the separation of read and write models, and event-sourcing for the log that makes projections replayable. What this domain contributes is what the derivation costs across a machine boundary — the lag, its failure modes, and the obligation it creates.
What the lag actually breaks
The generic warning "the view may be stale" is too weak to act on. The specific failures are these.
Read-your-own-writes. A user updates something, is redirected to a page served by the view, and sees the old value. This is the most common complaint and the most common reason teams abandon read models — usually solvable by serving that one screen from the source, or by pinning the reader to a position at least as new as their write. See [[read-after-write]].
Decisions made on stale data. A validation, an authorisation check or an inventory reservation performed against a view can be wrong in a way the view cannot detect. Any decision that must be correct belongs at the source of truth, and the view is for display and search. This single rule prevents most of the damage.
Non-monotonic reads. Two projections, or two replicas of one, at different positions: a user refreshes and sees a value go backwards. Nothing is broken, and users report it as data loss. [[monotonic-reads]] covers what to do.
Cross-view inconsistency. Two views built from the same stream at different positions disagree with each other, so a page assembled from both shows an internally contradictory state — an order marked shipped with no shipment.
# normal stream_head_offset 8_442_910 projection_applied_offset 8_442_907 projection_lag_events 3 projection_lag_seconds 0.41 <- the number in the contract # consumer restarted after 40 min outage stream_head_offset 8_610_388 projection_applied_offset 8_442_907 projection_lag_events 167_481 projection_lag_seconds 2_401 <- 40 minutes stale, no errors # what every dashboard showed during those 40 minutes read_model_query_error_rate 0.00 read_model_p99_ms 12 api_5xx_rate 0.001 # the view was serving 40-minute-old data, quickly and successfully.
Projections must be idempotent and replayable
Two properties make a projection operable, and both follow directly from the domain. Idempotence: delivery is at-least-once, so the same event will be applied twice, and applying it twice must equal applying it once. A projection that increments a counter on OrderPlaced is wrong; one that sets a value or tracks processed event ids is right. This is [[idempotent-operations]] at the projection layer, and getting it wrong produces totals that drift upward with no error.
Replayability: dropping the read model and rebuilding it from the stream must produce the same result. That is what makes the read model genuinely derived — and it is the escape hatch for every projection bug, because a fix plus a replay repairs history rather than only new events. It fails the moment a projection reads external state at processing time (calling a service for the current price) or accepts writes of its own. Both make the output depend on when the replay happens rather than on the events.
A practical consequence: projections should be pure folds over the event and their own prior state. If a projection needs data from elsewhere, that data belongs in the event. Enriching at processing time is the single most common reason a replay produces different answers, and it is discovered during the incident when the replay is needed.
1// Correct: pure fold, idempotent by construction, replayable.2function apply(view: OrderView, e: Event): OrderView {3 switch (e.type) {4 case 'OrderPlaced':5 // set, do not increment — a duplicate is a no-op6 return { ...view, id: e.orderId, status: 'placed', total: e.totalCents }7 case 'OrderShipped':8 return { ...view, status: 'shipped', shippedAt: e.at }9 }10}11 12// WRONG 1 — not idempotent. A redelivery inflates the total forever,13// and nothing errors. Discovered weeks later by a finance reconciliation.14// return { ...view, total: view.total + e.totalCents }15 16// WRONG 2 — not replayable. Reads external state at processing time,17// so a replay next year prices this order at next year's prices.18// const price = await pricingService.get(e.sku)19 20// Enrichment belongs in the event: the producer puts the price it used21// into OrderPlaced, so the fold stays pure and the replay is faithful.Publishing the lag, and deciding what depends on it
Because lag is the interface, treat it as one. Measure it in two units: events behind the head, and seconds of wall time. Events behind tells you the size of the backlog; seconds tells you what a user experiences, and only the second one is meaningful to a product owner.
Publish the position with the data. A response carrying as_of or the applied offset lets a consumer decide for itself: a dashboard can display "as of 14:02", a checkout path can refuse to decide on data older than five seconds and fall back to the source. Without it, every consumer silently assumes the lag is zero.
Alert on lag, not on errors. A stalled projection produces no errors — it produces fast, successful, wrong answers, which is why every dashboard in the excerpt above was green. The lag metric is the only signal that exists for this failure, and it must have a threshold derived from what consumers actually need rather than from what looks tidy.
And accept the obligation: a view that lags will occasionally be not merely behind but *wrong* — a dropped event, a projection bug, a bad deploy. Rebuild handles the systematic cases; the residual needs [[reconciliation]] as a standing component, which is the next lesson.
- Measure lag in events and in seconds; publish both.
- Return the applied position with the data so consumers can decide.
- Alert on lag — it is the only signal a stalled projection produces.
- Never make a correctness decision on the view; route those to the source of truth.
- Keep projections pure folds so replay is faithful and rebuild is a real repair.
- Test the rebuild on a schedule, and know how long it takes before you need it.
Key points
- Events → projection → read model, and the read model lags by an amount that must be measured.
- The view is consistent with a prefix of the stream, not with the present.
- Lag is the interface: publish the applied position with the data so consumers can decide.
- Correctness decisions belong at the source of truth; the view is for display and search.
- Projections must be idempotent (at-least-once delivery) and replayable (pure folds, no enrichment at processing time).
- A stalled projection produces no errors — only the lag metric shows it.
The chain, answered
Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.
- • The source of truth commits a change and emits an ordered, durable event.
- • A projection consumes events from its recorded position.
- • For each event it applies a deterministic, idempotent update to the read model.
- • It advances its position, and the difference between that position and the stream head is the lag.
- • Readers query the read model directly, receiving its applied position alongside the data.
- • On a projection change or a detected fault, the model is dropped and rebuilt by replaying the stream.
- • Events are delivered more than once, so a non-idempotent projection accumulates errors.
- • Events arrive out of order across partitions, so a projection assuming global order computes the wrong state.
- • The consumer stops and the view silently serves old data.
- • An event is lost or skipped and the view is wrong in a way that replay would fix but nothing detects.
- • A projection bug corrupts the model, and the corruption is only visible by comparison with the source.
- • A rebuild takes far longer than anyone estimated, so the escape hatch is unavailable when needed.
- • Stale-but-healthy: the operator sees the read model answering in 12ms with a zero error rate while serving data from forty minutes ago, because only the lag metric would have shown it.
- • Missing-write complaint: the operator receives reports that a change "did not save", and finds it saved correctly at the source but not yet projected into the screen the user was redirected to.
- • Drifting totals: the operator sees aggregate figures diverge slowly from the source over weeks, because a duplicate delivery increments rather than sets.
- • Contradictory page: the operator sees an order displayed as shipped with no shipment record, because two views built from the same stream sit at different positions.
- • Backwards value: the operator receives reports of a value reverting on refresh, because two replicas of the view are at different positions and the load balancer alternates.
- • Rebuild too slow: the operator starts a rebuild during an incident and discovers it takes nine hours, having never been timed.
- • Building the view requires no coordination with readers or with the source — that is what makes it scale, and why it survives the source being briefly unavailable.
- • Read-your-own-writes reintroduces coordination for one path: pin the reader to a position, or serve that screen from the source. Both are deliberate exceptions, and should be few.
- • Multiple views from one stream need no agreement with each other, which is why they drift apart; if a page needs them consistent, compose it from one view instead of several.
- • A rebuild is a coordination event with capacity: replaying millions of events competes with live traffic for the same resources, and must be rate-limited rather than run flat out.
- • While the projection is stopped, the view keeps serving its last consistent prefix — correct as of a past moment, and useful for display.
- • Because the view is a prefix of an ordered stream, it is internally consistent even when stale, which is a stronger property than an arbitrary cache offers.
- • While the source is unavailable, the view keeps serving reads; only writes are unavailable. That is often the main reason the view exists.
- • Events buffered during an outage are applied on recovery, so the view converges — provided the stream retained them, which is exactly the assumption that fails after a long outage.
- • Detect: alert on lag in seconds against a threshold derived from consumer needs, and on the projection’s liveness independent of its error rate.
- • Contain: serve affected reads from the source of truth, or mark responses as stale, so consumers stop treating the view as current.
- • Recover: restart the projection and let it catch up, with the catch-up rate limited so it does not saturate the store it writes to.
- • Reconcile: compare the view against the source for the affected window and repair the delta — catching up on offsets does not fix events that were dropped or misapplied.
- • Verify: confirm lag is back within threshold and the comparison returns zero, then time the rebuild path so the estimate is real next time.
- • Lag in events and in seconds, per projection and per partition — a single global figure hides one stuck partition.
- • The applied position, returned with query responses so consumers can act on it.
- • Delta between the read model and the source of truth, sampled continuously.
- • Rebuild duration, measured on a schedule rather than estimated during an incident.
- • Duplicate-event rate at the projection, which is normal and non-zero and should not be rising.
- • Query patterns the write model cannot serve efficiently — full-text search, cross-entity aggregates, per-user timelines.
- • Read-heavy workloads where the read shape differs strongly from the write shape and joins are expensive.
- • Isolating read load from the write path, so an expensive report cannot degrade order placement.
- • Multiple consumers needing different shapes of the same underlying facts.
- • When any consumer needs read-your-own-writes on the same screen, unless you have designed for it explicitly.
- • For decisions that must be correct — validation, authorisation, reservation — which belong at the source.
- • When a single well-chosen index on the source would have served the query, at a fraction of the operational cost.
- • Where the event stream does not carry enough information to rebuild the view, making replay impossible and the model quietly authoritative.
- • An index or a covering index on the source: no lag, no new component, no reconciliation obligation. Try this first, and most of the time it is enough.
- • A read replica of the source: same schema, no projection logic, and lag that is measured for you by the database.
- • On-demand computation with a short cache TTL: simpler, bounded staleness, and it degrades to slow rather than to wrong.
- • A periodically refreshed database materialized view, where the engine owns refresh and lag is a schedule you set rather than a pipeline you operate.
A read model is consistent with a prefix of the log
{
"results": [ ... ],
"applied_position": 32840,
"log_position": 45900,
"lag_events": 13060,
"lag_seconds": 10.20
}What people believe, and what is true
The read model is basically a cache.
It is a cache with an important extra property: it is consistent with a prefix of an ordered stream, so it is internally consistent even when stale. An ordinary cache offers no such guarantee.
Lag is small, so we can ignore it.
Lag is small under normal conditions and enormous during exactly the conditions you build for. Write the consumer contract against the bad case and publish the position.
The view had no errors, so the data was fine.
A stalled projection serves fast, successful, stale answers. Error rate is blind to this failure; only the lag metric sees it.
We can enrich events by calling a service in the projection.
Then the output depends on when you process, and a replay produces different answers. Put the enrichment in the event at production time.
If the view is wrong we can just rebuild it.
Only if the stream retains enough history, the projection is a pure fold, and someone has timed the rebuild. Each of those is an assumption that fails first during an incident.
Go deeper
Only the levels this lesson can honestly fill — a missing level is a claim nobody had.
Overview
Events feed a projection that builds a read model shaped for one query. The read model lags. Measure the lag, publish it, and never make a correctness decision on it.
Practical
Keep projections pure and idempotent so duplicates are harmless and replay is faithful. Track lag in events and in seconds, per partition, and alert on it — no error rate will show a stalled projection. Return the applied position with query results. Time the rebuild before you need it.
Advanced
A materialized view is a cache whose invalidation is expressed as a fold over an ordered log rather than as an eviction rule. That is why it beats ad-hoc caching on correctness: the state is always a deterministic function of a prefix of the log, so it is internally consistent at every moment and repairable by replay. The price is that everything hangs on the log — its ordering, its retention, its delivery semantics — and every projection bug becomes a question about how far back you can replay.
Apply it
- 🔧 Add lag-in-seconds monitoring to one projection and set the threshold from what its consumers actually need, not from what looks tidy.
- 🔧 Time a full rebuild of your largest read model in a non-production environment, and write the number in the runbook.
- ⚡ A page shows an order as shipped with no shipment record. Both facts come from views built off the same stream. What is the cause, and what would prevent it?
- 💬 What does a materialized view guarantee, precisely?
- 💬 A user updates their profile and the next page shows the old value. What is happening and what are your options?
- 💬 Why must a projection be idempotent, and what does a non-idempotent one look like in production?
- 💬 Your projection calls a pricing service while processing events. What breaks, and when do you find out?