Why the CRM trails the orders, and why you can watch it
An order is decided at once, and the CRM learns of it a moment later. This is why the bakery is built that way, what joins the two sides, and how the console lets a visitor watch the gap under load instead of hiding it.
The order is on the board, and not yet on the timeline
A visitor opens the console and presses Start traffic at 6 orders a minute. Made-up customers start ordering. A card appears on the kitchen board almost as soon as each order is placed. In the customer list, that customer’s count of orders goes up later, once the payment has landed and the list has looked again. Nothing failed. Every order was accepted the moment it was placed, and the views are catching up.
What a simpler design does, and where it breaks
The obvious design reads the truth at the moment it is asked. A customer’s history would ask every Order object that customer ever had, and the campaign stats would ask all of them, a request each for every page view. docs/adr/0004 rejects that as projections polling their sources. The other obvious design has the Order write into the CRM as it commits. A Durable Object’s transaction ends at its own storage, so that is a call, not a write, and the Order would wait on the CRM: a slow CRM would slow every checkout. No lab page shows this one yet. The lab’s index is where the lab explains the family of techniques around it; as read on 2026-11-06 its Queue and Backfill Labs show the parts, and none shows the split.
Two sides with different needs
Greg Young’s CQRS Documents (2010) splits a service into a command side and a query side because they want different things: commands want consistent data and a transaction, while most systems can let the query side be eventually consistent. The bakery takes that literally. The Order, Customer and CateringEvent objects take commands and decide them. The Crm object keeps the views the console reads, the customer list, the timelines, the activity feed, the lead pipeline and the campaign stats, and no command ever writes them (crates/domain/src/crm.rs). The only thing between the two sides is the Queue.
How an event crosses
An Order commits its events and a copy of each in its outbox in one transaction, answers the visitor, and sends the outbox to the bakery-events Queue straight after; an outbox timer 30 seconds out sends whatever did not go (inworkers/api/src/objects/stream.rs). The consumer takes batches of up to 10 with a batch timeout of 0, so a message goes as soon as it is there (wrangler.jsonc). Each subscriber gets the event at its own path, and a message is acknowledged only when every subscriber answered 2xx; otherwise it is retried, and after 3 retries it moves to the dead-letter queue (workers/api/src/queue.rs). Delivery is at least once, and Cloudflare’s documentation, read on 2026-11-06, promises no order.
So the CRM has to make a correct view out of events that can come twice and out of order. It keeps the ids it applied, so a second delivery changes nothing. For each order, catering and customer stream it keeps the newest seq it applied and ignores anything not newer. It does not wait for gaps, because it receives only some of each stream’s events by design. It is not Hohpe and Woolf’s resequencer, which buffers messages until the order is whole. The one case it cannot apply is a later event for an order it has never seen, such as OrderPaid ahead of OrderPlaced: it refuses it as an unknown reference, and the Queue’s retry brings it back after the placement has arrived.
The lag is the design
The write that must be right now is the order: its place, its price, its payment. The read that may be a moment old is the timeline. Splitting them means the checkout never waits for a view, and a view can be thrown away and rebuilt from the CRM’s log (the backfill principle). The cost is that the screens are behind, and they say so. The console’s customer list and its activity feed look again every ten seconds (apps/web/src/components/Customers.tsx, Activity.tsx), and the tour tells the visitor that the CRM learns of a change “a moment later”. While a rebuild runs, the CRM’s answers carry Projection-State: rebuilding. That header marks a rebuild only; no screen yet shows how many events a view is behind.
The kitchen board is a projection too, fed by the same Queue. It looks faster because it is pushed: a socket gets a snapshot when it joins and a frame for every event that changes the board.
Load you can start, and that stops itself
A quiet demo has nothing to catch up on. The Simulator object, one per sandbox, places orders on its own alarm, one tick a wall-time minute, as the seed’s made-up customers (inworkers/api/src/objects/simulator.rs). Each order goes the way a visitor’s does: the same idempotency key check, a place asked of the day’s Schedule, a payment through Stripe’s test mode, one in six declined. Its events carry cause: sim:<ulid>, so the feed tells them apart. Because it passes the same checks, it is refused like a visitor: a smoke run at 03:30 UTC found its slots, four hours ahead, falling before opening, and full slots kept it under its own rate limit, so the smoke now moves the business clock to the next opening before it runs (docs/roadmap.md).
It is bounded three ways. At most 6 orders a wall-time minute, the next logged sim_rate. At most 30 minutes a sandbox a UTC day, reserved in Control’s ledger, a start past them refused and logged sim_cap. And only while the console is open: a tick that has seen no socket and no heartbeat (sent every 30 seconds) for 90 seconds stops with console_closed. A demo left in a tab that was closed cannot run up a bill on its own.
What it costs
- One full run. 30 minutes at the top rate is 180 orders, about 546 Queue operations, 243 Durable Object requests and 3,000 rows written, wipe included (from
docs/cost.md, planning estimates as of 2026-11-06). - Three operations a message. A write, a read and a delete, and a read more for each retry (Cloudflare’s Queues pricing, read on 2026-11-06). On a day at the 300-sandbox cap,
docs/cost.mdcounts room for about 9 full runs in the Queue budget. - Views that are behind. Any screen built on the CRM can show a state a few seconds old, and a reader has to know which screens those are.
Try it
Sign in and open the console. Press Start traffic and watch cards arrive on the kitchen board, then watch the customer list’s Orders and Last order columns follow them. Close the tab, and the Simulator stops on its own at its first tick after 90 seconds.
Sources
- CQRS Documents, Greg Young, 2010, read on 2026-11-06: a service split into a command side and a query side, with consistent transactions on the first and eventual consistency on the second, kept in step by events.
- Resequencer, Gregor Hohpe and Bobby Woolf, Enterprise Integration Patterns (2003), read on 2026-11-06: collecting messages that arrive out of order and publishing them in order, which the CRM deliberately does not do.
- Batching, retries and delays, Cloudflare’s Queues documentation, read on 2026-11-06: a batch is delivered when it reaches its size or its timeout, whichever comes first; a message acknowledged on its own is not delivered again; three retries by default, then the dead-letter queue.
- Delivery guarantees, Cloudflare’s Queues documentation, read on 2026-11-06: messages are delivered at least once and occasionally more than once.
- Queues pricing, Cloudflare’s Queues documentation, read on 2026-11-06: a message takes 3 operations, a write, a read and a delete, and each retry another read; 1,000,000 operations a month on Workers Paid, then $0.40 a million.
- The lab, the lab index on ihaveasegway.com, read on 2026-11-06: five lab pages, the Route, Queue, Cache, Backfill and Raft Labs; the Queue and Backfill Labs show the parts, and none shows the split itself.
Back to the CQRS principle or load you can watch on How it’s built, to the decisions that chose them, ADR 0003 and ADR 0004, or to the lab.