Pick the right primitive
Two layers: synced data vs. ephemeral signals
The first four rows above are synced data — they flow through the entity store, respect policies, and land in the local replica so they survive reload and work offline. The remaining rows —useRoom (presence/broadcast), the two server-push primitives,
and useShard (simulation) — are ephemeral signals. A cursor position or a game
tick has no business in your database; it’s broadcast to the room’s current members
and forgotten. Don’t model presence as an entity, and don’t try to drive a 60fps game
loop through db.useQuery.
Streaming server output: one client, or all of them
Both server-push rows above run inside a mutation or action (neither exists on query ctx). They differ in who receives the output:ctx.stream.write(text)writes to the HTTP response of the call in flight. It reaches exactly one client — whoever made the request, reading it back withdb.streamFn(fn, args). Close the tab and the output is gone.ctx.rooms.broadcast(room, topic, data)pushes over the WebSocket to every subscriber of a presence room, whether or not they made the call. It resolves{ delivered: false }when the room has no members — an empty room is a no-op, not an error.
ctx.stream.write for a chat box waiting on its own answer. Use
ctx.rooms.broadcast when the output has to reach more than the caller: an agent whose
tokens must survive a reload, a second device following the same session, or a
scheduled job that has no HTTP caller at all. Handlers commonly do both — write to the
caller for the lowest-latency path, broadcast so every other watcher stays in sync.
Clients join with useRoom(roomId, userId) and read pushed messages off the sync
engine:
Best practice: cross-tab live state goes through db.useQuery
The most common realtime feature is a shared scalar that must update everywhere at
once — a live counter, remaining capacity, “12 people viewing”. The reliable way to
build it is a public, PII-free projection entity subscribed with db.useQuery,
not a reactive query.
Reactive server queries (db.useReactiveQuery) behave as leader-tab-only in
practice: a follower tab’s reactive subscription may never deliver its initial
result. Entity sync (db.useQuery) reaches every tab. So:
- Keep the sensitive table deny-all:
allowRead: "false". - Have the mutation also maintain a tiny projection entity with
allowRead: "true"and client writes denied. - Subscribe the UI to the projection with
db.useQuery.
db.useReactiveQuery where it shines — a server-side join or rollup rendered in a
single (leader) view, like a feed that joins Post → User. Just don’t lean on it for
cross-tab fan-out of a shared value.
How it works
Under the hood every subscription rides one WebSocket per client. The server keeps an index of active subscriptions keyed by entity + indexed filter fields, so a write fans out in O(matching subs), not O(all subs). On multi-machine deployments a cluster bus forwards change events between nodes — a mutation on machine A updates subscribers on machine B. See horizontal scaling for the architecture.Next
Live queries
The
db.useQuery workhorse and how fan-out scales.React client
Every realtime hook the client exposes, including presence and shards.