Skip to content

Eventual Consistency Needs a Read Barrier

Diagram showing the read-after-write consistency pattern with position tokens

A system accepts a write, returns success, and then immediately serves a read that does not reflect it.

That is a common failure mode in event-driven systems. It shows up as duplicate submissions, dashboards that look broken, and APIs that feel unreliable under load. The usual fixes are sleeps, retries, or optimistic UI. None of those define a correctness boundary.

The real issue is simpler. The write path usually has a clear durability boundary. The read path often has no corresponding freshness boundary. The system knows when it accepted your write, but it often cannot say whether a given read includes it. That gap needs a mechanism.

When writes and reads take different paths, there is a window between durable acceptance and visible state. A client appends an event to a log. The log stores it and returns success. A separate projection process later folds that event into queryable state. Reads hit the projection, not the log.

A simple timeline looks like this:

Client appends event
-> Log durably stores it at sequence 5
-> Append returns success
-> Projection processes event 5
-> Client queries the view
-> View may still be at sequence 4

That window is the consistency gap. Faster projections shrink it. They do not remove it. Under load, during failover, or while replaying from snapshots, the gap grows. If the API just returns whatever the view currently has, then every caller is implicitly opting into stale reads.

There are a few ways to handle this.

  • You can return stale reads and accept the inconsistency. That is simple, and sometimes fine for feeds or analytics, but it is a bad fit for write-then-read flows.

  • You can update the view synchronously on the write path. That gives straightforward semantics, but it couples write latency to projection work and gives up much of the point of asynchronous views.

  • You can tell clients to sleep and retry. That is not a consistency mechanism. It is a guess about timing.

Or you can expose a read barrier.

A read barrier is a lower bound on freshness. When the system durably accepts a write, it returns a monotonic position such as a sequence number, offset, or LSN. A later read can include that position and say: do not answer until the view has processed at least this far.

In abstract terms:

  • let pos(w) be the durable position returned by write w
  • let applied(V) be the highest position view V has processed

A query against view V with after = p may execute only when:

applied(V) >= p

If the read succeeds, the caller knows the result includes all log entries through at least p as applied by that view.

That guarantee is narrow. It is local to one view and one log. It does not make the system linearizable, and it says nothing about other views unless they are explicitly part of the same protocol.

A write returns a durable position:

Terminal window
curl -X POST https://api.primatomic.com/logs/$LOG_ID/append \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/octet-stream" \
-d '{"type":"order_placed","order_id":"abc-123","total":99.00}'
{ "success": true, "sequence": 5 }

A later query can require a view that has processed through that point:

Terminal window
curl -X POST "https://api.primatomic.com/logs/$LOG_ID/views/$VIEW_ID/query?after=5" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/octet-stream" \
-d '{"order_id":"abc-123"}'

after=5 means: do not answer until this view has processed at least sequence 5. Without after, the caller gets the current state of the view with no coordination cost and no freshness guarantee. With it, the caller is asking for a bounded wait in exchange for a stronger guarantee.

Without a read barrier, the system can answer “was the write accepted?” but not “does this read reflect it?”

Suppose a client submits order_placed, gets success back, reloads the order page, and the projection has not caught up yet. The client sees nothing and submits again. That second write is not caused by eventual consistency in the abstract. It is caused by an API that acknowledged the write but gave the caller no way to fence the next read against it. A read barrier closes that gap.

Waiting is easy. The difficult part is defining what the wait means and what happens when it cannot be satisfied.

A typical flow looks like this:

Query arrives with after=5
-> Check current applied sequence for the view
-> If applied >= 5, execute immediately
-> Otherwise, wait for the view to advance
-> Re-check after wakeup
-> Execute once applied >= 5
-> Or return an explicit timeout / unavailable error

The wakeup mechanism is not especially interesting. A condition variable, broadcast channel, or similar primitive works fine. What matters is the invariant behind it. A view should only publish that it has reached sequence N after the state transition for event N is fully applied and queryable. If that invariant is loose, then the barrier is loose.

Failure handling matters even more. “Not caught up yet” is different from “healthy but too slow” and different again from “cannot reach that position because processing is failed or blocked.” A barrier is only useful if the API distinguishes those cases instead of collapsing all of them into silence or indefinite waiting.

In a distributed system, the node serving the query may not be the node advancing the view. That means the barrier is not just a local check unless the system guarantees that barriered reads reach the right owner. If leadership changes while a query is waiting, the contract still has to hold. Progress cannot appear to move backward just because the request crossed a handoff boundary. That is one reason this pattern is more subtle at the application layer than inside a storage engine. Primatomic implements this by returning a X-View-Leader header in the response for caching on the client side, falling back to proxying the request to the leader of a given view.

A read barrier is not free. If the view is already caught up, the common case cost can be very small. Often it is just a lookup of the current applied sequence and an integer comparison. If the view is behind, the cost becomes waiting time plus resource occupancy. Enough blocked requests can exhaust worker pools, sockets, or connection budgets if the system is not careful. That is why barriered reads need timeouts, cancellation, and some form of backpressure when the projection is unhealthy. The point is not to hide a slow or broken projection behind a long hang. The point is to make freshness explicit and bounded.

A read barrier is easy to define for one view. It gets less clear once a request depends on several projections. Suppose one event updates both orders_view and inventory_view. A query with after=N against orders_view only tells you that orders_view has processed through N. It says nothing about inventory_view unless the system tracks progress for both and waits for both.

So a single barrier is a per-view guarantee, not a general solution for combined reads. Once a request spans multiple projections, you need separate barriers, a composite wait, or a different serving path.

A read barrier makes sense when writes are durably ordered, reads come from an asynchronous derived view, and write-then-read flows matter enough that stale reads are not acceptable. It is much less useful when reads and writes already share a transactional store, or when projections are so far behind that every barrier just turns stale reads into timeout errors. The pattern does not remove eventual consistency from the system. It gives the caller a way to name the minimum freshness they need for a particular read.

That is the important part.

If a system can name the durable position of a write and measure the applied position of a view, it has enough information to expose a real freshness contract. Once that exists, the caller is no longer reasoning about timing. They are reasoning about an explicit lower bound.

Eventual consistency is manageable. What breaks systems is leaving freshness undefined.


This is the consistency model Primatomic exposes for view queries through ?after=N. The same pattern applies to any system with a durable mutation stream and asynchronously maintained read models.