A command in Bounda commits its events and returns. The read models learn about them afterwards: each projection is a subscriber of the global stream, and a dispatcher pass moves it past the new events (how Bounda runs). Between the commit and that pass, a query reads the read model as it was. That gap is what eventual consistency means in practice, and most of the time nobody notices it.
A form submission notices it. In the onboarding example, the register
action dispatches registerUser and redirects to /users/<id>, whose loader asks getUserDetails
for the row and answers 404 when there is none. Without a wait, a redirect that lands before the
user-details projection has run tells the user who just registered that they do not exist. React
Router makes this the common case, not the rare one: once an action completes, it
revalidates every loader on the page.
Whether a request has to wait for its own writes is something only the host serving the request knows, so the host decides it. The core offers the means and no default.
What the core offers
Section titled “What the core offers”Two primitives in @bounda-dev/core, both working in every role:
app.catchUpReadModels({ through }) waits for the read models a command’s events reach, and only
those, and readYourWrites(app) returns the app with a commands facade that does it after every
dispatch. The wait is bounded, and the commands policies and processes dispatch never wait.
Reading your own writes has how they wait and
their settings.
Who decides, and how
Section titled “Who decides, and how”- React Router.
createBounda({ consistency }), orbounda({ consistency })in the Vite plugin, defaults to"read-your-writes": on Node the app it puts in every request’s context is wrapped inreadYourWrites, and on Cloudflare the setting goes toconnect."eventual"serves the app as booted, for pages that tolerate the delay (Reading what you just wrote). - Cloudflare.
connect(stub, { consistency })andcreateWorker({ consistency })default to"read-your-writes": the Durable Object’s command call runscatchUpReadModels({ through })after the dispatch and before it answers, so the projections run inside the command’s request."eventual"answers once the events are stored, and the object’s alarm projects them right after. Policies, processes and scheduled commands run in the alarm either way (how it runs). - Anything else. A script, a test or another framework picks:
readYourWrites(app), onecatchUpReadModelswhere it matters, or nothing. The storefront’s script dispatches and then callsrunUntilIdle(), which is right for a script that wants every consequence before it prints.
Why not the other ways
Section titled “Why not the other ways”runUntilIdle()in every action. It looks like the simple answer, and it is the wrong amount of work. It runs every subscriber, policies and processes included, so a policy that calls a slow provider would hold the request; and it runs due scheduled commands. It goes on until nothing moves, with no time limit. It ignores the back-off, so every request would trip over a failing projection again. And it waits on a read model’s lock, which on PostgreSQL holds a connection while another instance finishes its batch.catchUpReadModels({ through })does the one part a page needs: the read models this command touched, up to this command.- A consistency default in the core. Most dispatches have no read behind them: a worker, an import script with ten thousand commands, a webhook that answers 200. A default in the core would make all of them pay for a read they never do, or make each turn it off. Where a read follows is a fact about the request, which the core never sees; the host does, which is why React Router and Cloudflare each take it as a setting of their own.
What other frameworks do
Section titled “What other frameworks do”- Marten lets a projection be inline, updated in the same unit of work, and the same
database transaction, as the events; async, run by a daemon in the background and
eventually consistent; or live, built on demand. For async ones it offers
QueryForNonStaleData, which waits for the daemon to reach the event store’s position when the query started, and warns it can time out or slow responses down (projections, reading aggregates). - EventStoreDB/Kurrent returns the commit position of an append, and its docs suggest using it when redirecting a user to an eventually consistent view (appending events).
- Axon pushes instead of waiting: a subscription query returns an initial result and then the updates a projection emits after it changes its model, so a client sees a write when its update arrives instead of by asking again.
Bounda takes Kurrent’s idea, a write’s position, and builds the wait on it: a command’s result carries its position, and the request waits for checkpoints to reach it, bounded, and only for the read models the command touched. It has no inline projections, and could not have them for a read model in a database of its own, which no transaction of the event store reaches.
What stays outside
Section titled “What stays outside”- Your own writes, not everyone’s. A command waits for its own position; another user’s command committed a moment later may not be there yet.
- Reactions. What a policy or a process does in response, a payment, a confirmation, lands later. A page that shows it has to say it is pending, or poll.
- A stale page, not a hang. Past the timeout, or with a failing projection, the request reads what is there. The warning it logs is how you find out.
- A query in a handler. Inside a policy or a process, a query sees what was committed before the attempt, never what the attempt has staged (what stays outside the outbox).
Where to read more
Section titled “Where to read more”- Reading your own writes, for the API and the settings, and Bounda with React Router.
- The Cloudflare adapter, for read models in the command’s request or in the alarm, and reactions in the alarm.
- The other frameworks: Marten’s projections and inline projections, Kurrent’s appending events, Axon’s subscription queries, and React Router’s actions.