Ch. 10 · Microservices

Microservice Read Models and Eventual Consistency

Building query views from events, why a user can't see the order they just placed, and how position tokens and idempotent projectors help.

~6 min readadvancedupdated Oct 7, 2026

Once each service owns its data, some queries get awkward. “Show my orders with their payment status and delivery estimate” needs data from orders, payments and shipping. You could call all three on every page load (API composition), or you could keep a dedicated copy, shaped exactly for that page, that updates whenever any of them publishes an event. That copy is a read model, and the component that maintains it is a projector or projection.

Read models are the query half of CQRS (Command Query Responsibility Segregation): commands go to the services that own the data and enforce the rules, and queries go to views optimised for reading. The price is that the view is always slightly behind. Interviews on this topic are mostly about that price: how far behind, what breaks, and how you hide it from users.

A projection you can watch fall behind

The model below has a write side (the order service appending events to a log, as an outbox and a Kafka topic would) and a read side (a “my orders” view, built by a projector that polls the log every 120 ms).

// Write side: the order service appends events to a log (think: outbox -> Kafka topic).
function placeOrder(customerId, orderId, totalCents) {
  log.push({ position: log.length + 1, type: 'OrderPlaced', customerId, orderId, totalCents });
  return log.length;                                   // position of the event just written
}

// Read side: a projection builds "my orders" for each customer, a little behind the log.
const view = { position: 0, byCustomer: new Map() };
function project(e) {
  if (e.position <= view.position) return;             // already applied: replays are harmless
  const list = view.byCustomer.get(e.customerId) ?? [];
  if (e.type === 'OrderPlaced') list.push({ orderId: e.orderId, totalCents: e.totalCents, status: 'PLACED' });
  if (e.type === 'OrderCancelled') list.find((o) => o.orderId === e.orderId).status = 'CANCELLED';
  view.byCustomer.set(e.customerId, list);
  view.position = e.position;
}

async function myOrdersAtLeast(c, minPosition, timeoutMs = 1000) {
  const start = performance.now();
  while (view.position < minPosition) {
    if (performance.now() - start > timeoutMs) return 'still catching up, try again';
    await sleep(10);
  }
  return `${myOrders(c)}  (waited ${Math.round(performance.now() - start)} ms)`;
}
JavaScript

The script places an order, reads immediately, reads again after 150 ms, cancels, reads immediately, then reads with a position token. Finally it throws the view away and rebuilds it from the log, twice. Output from Node 22:

right after placing o-991:       (none)
150 ms later:                    o-991:PLACED
right after cancelling:          o-991:PLACED
read with position token 2 :    o-991:CANCELLED  (waited 87 ms)
after rebuilding from the log:   o-991:CANCELLED
Text

(The wait time depends on where the projector’s polling cycle happens to be, so it changes between runs.)

What the lag looks like to a user

Line 1: the customer places an order, the confirmation page links to “My orders”, and the list is empty. Line 3: the customer cancels, the page refreshes, and the order still says “placed”. Both are correct behaviour for an eventually consistent view. Both generate support tickets (“I was charged but my order is missing”, “I cancelled but it didn’t work, so I cancelled again”).

In a real system the lag comes from the outbox relay’s polling interval, broker latency, consumer batching, and the projector’s own write time. Typically tens to hundreds of milliseconds. During an incident or a consumer rebalance it can be minutes. Design for the bad case, not the typical one.

Read-your-writes: four ways to hide the lag

Most users don’t care whether other people’s changes take a second to appear. They care a lot about their own. Techniques, roughly from cheapest to most involved:

  • Update the UI from the command’s response. The cancel call returns the new state; the client shows “cancelled” without re-reading the view. Cheap and effective, but a page reload brings the stale view back.
  • Position tokens. The write side returns the log position of the event it produced (here, 2). The client sends it with the next query, and the read side waits until it has applied at least that position, as myOrdersAtLeast does. The output shows it working: the read with token 2 waited 87 ms and returned CANCELLED. With Kafka, the token is a partition and offset; with a database-backed projection, the projection’s checkpoint.
  • Read from the owner for recent changes. For a few seconds after a write, the client (or a BFF) fetches that one entity from the owning service and merges it into the list.
  • Make the lag part of the product. “Your cancellation is being processed” is honest and usually acceptable, especially when the operation really is asynchronous.

The wait must have a timeout. If the projector is down, an unbounded wait turns a stale view into a hung page.

A related property is monotonic reads: a user should never see newer data and then older data. That happens when consecutive requests hit different replicas of the view at different positions. Position tokens fix this too, or sticky routing to one replica.

Projectors must survive replays and rebuilds

The projector will see events more than once: after a crash before it saved its checkpoint, after a consumer rebalance, after an intentional replay. The if (e.position <= view.position) return guard makes it idempotent, which is why the second replay in the output changed nothing. Store the checkpoint in the same transaction as the view update so they can’t disagree.

Rebuilding is the superpower of read models. The last line of the output came from discarding the view entirely and replaying the log. That’s how you fix a projection bug, add a new column, or build a new view for a new screen. To do it without downtime, build the new version alongside the old one (a new table, a new consumer group starting from the beginning), wait until it catches up, then switch queries over. That’s only possible if the events are still available, which means a topic with long enough retention, log compaction, or an event store.

What not to use a read model for

The read model is for showing data, not for making decisions that must be correct right now. A classic mistake is checking stock levels in a product-listing read model before accepting an order. The view says 3 units are left, but it’s 400 ms behind, and those units sold 200 ms ago. The decision has to be made by the service that owns the stock, inside its own transaction.

The same applies to uniqueness checks (“is this username taken?”), balance checks and permission checks. Use the read model to give the user a helpful hint; let the owner enforce the rule.

Interview practice: the dashboard that shows yesterday’s numbers

“A seller dashboard is a read model built from order, payment and refund events. After a deploy, sellers report that refunds from the last two hours are missing, but new orders appear fine. Refund events are on the topic. What would you investigate, and how would you repair the data?”

Answer

Orders appear and refunds don’t, so the projector is consuming but not applying refunds. I’d check three things. First, the projector’s logs and error metrics for refund events: a deploy that changed the refund event shape (a renamed field) can make the handler throw, and if errors are caught and logged, the checkpoint keeps advancing past events that were never applied. Second, whether refund events are on a different topic or partition whose consumer stopped, which the consumer lag metric per topic would show. Third, whether the new projector version has a version or position guard that wrongly treats refunds as already applied.

To repair, fix the handler, then replay. If the projector is idempotent and the topic retains at least two hours of events, resetting the consumer group’s offset to before the deploy and replaying is enough, since orders already applied are skipped. If idempotency isn’t guaranteed, I’d rebuild the view into a new table from the start of retention and switch over once it’s caught up. Afterwards I’d make handler failures stop the projector or route the event to a dead-letter topic, rather than silently advancing the checkpoint.

Further reading

More in Microservices

esc