Realtime events & presence
One WebSocket connection per tab. A channel-based pub/sub hub. Domain events in, ServerEvent DTOs out.
Orbit ships realtime out of the box: every open tab holds a WebSocket to the API, and every domain event that happens — a member joining, a role changing, a subscription updating — lands in every other tab within a tick. The mechanism is deliberately small: no external broker, no Redis, no Pusher — an in-process pub/sub hub that reads from the event bus.
In-process means in-process: one API node equals one hub. Scale beyond a single node by replacing InProcessRealtimeHub with a Redis or NATS-backed implementation of the same interface, or by sticky-routing WebSockets to the node that owns the workspace.
Channels
The hub is pub/sub over string channel IDs. Three helpers name them consistently:
apps/api/src/realtime/hub.ts
export const channels = { workspace: (id: string) => `workspace:${id}`, room: (id: string) => `room:${id}`, member: (id: string) => `member:${id}`,};| Channel | Who subscribes | What lands here |
|---|---|---|
workspace:{id} | Every socket for members of the workspace | Members, roles, teams, subscriptions — anything workspace-wide. |
room:{id} | Only sockets subscribed to that room | Room-scoped events. Useful for feature folders that add their own channels. |
member:{id} | Every socket for one workspace-member | Direct signals to one member across their open tabs. |
The connection lifecycle
The WebSocket controller lives at apps/api/src/interfaces/ws/. Each connection follows the same four steps:
- Upgrade at
/v1/ws. The handler resolves the session from cookies, reads?workspace=<slug>, and verifies the user is a member of the workspace. Authentication runs during the HTTP upgrade — a mismatch writesHTTP/1.1 401 Unauthorizedback and aborts the handshake before any WebSocket is established. - Register the socket. The controller builds a
SocketHandle(id, send, close, workspaceId, workspaceMemberId, userId) and hands it tohub.registerSocket(sock, [workspace, member]). The member channel lets the presence tracker count sockets per member. - Stream ServerEvents. Every
hub.broadcast(channel, event)serializes to JSON and ships to matching sockets. - Heartbeat & cleanup. A 25-second ping loop terminates sockets that don't pong. On
close, the hub unregisters the socket and the presence tracker emits apresence.updateif this was the member's last connection.
How domain events become ServerEvents
RealtimeEventPublisher is a projector. It subscribes to each event type on the bus and translates them, one by one, into ServerEvent DTOs (declared in @orbit/shared/realtime). A typical handler re-reads the committed aggregate, hydrates the DTO it needs, and broadcasts:
this.bus.subscribe<WorkspaceMemberJoined>( "workspaces.member.joined", async (event) => { const dto = await this.uow.read(async (tx) => { const m = await tx.workspaceMembers.findById(event.memberId); if (!m) return null; const [user, role] = await Promise.all([ tx.users.findById(m.userId), tx.workspaceRoles.findById(m.roleSnapshot.id), ]); return workspaceMemberToDTO(m, user, role); }); if (!dto) return; this.hub.broadcast(channels.workspace(event.workspaceId), { type: "workspace.member.joined", member: dto, }); },);The projector's uow.read() can come back empty if the aggregate was deleted in a racing transaction between the commit and the broadcast. The handler silently drops that event — clients never see a broadcast about a row that no longer exists.
Presence
PresenceTracker keeps a per-workspace set of online members and flips them on/off based on socket counts:
- First socket for a member → broadcast
presence.update(online: true) on the workspace channel. - Last socket closes → broadcast
presence.update(online: false). - Idle sockets between join/leave don't re-broadcast — presence only flips on transitions.
The client side keeps the current presence set in a dedicated store, so the sidebar "online now" indicators react without any polling.
On the client
apps/web-tanstack/src/lib/db/realtime.ts holds the whole client. It opens a WebSocket pointed at ${VITE_API_URL}/v1/ws?workspace=${slug}, parses each message, and dispatches into a set of entity stores:
function applyServerEvent(event: ServerEvent): void { switch (event.type) { case "workspace.member.joined": membersStore.insert(event.member); return; case "workspace.member.role_changed": membersStore.update(event.member.id, event.member); return; case "workspace.role.created": rolesStore.insert(event.role); return; case "presence.update": presenceStore.set(event.memberId, event.online); return; // ...one case per ServerEvent type }}The stores are TanStack Stores keyed by entity id. Components read them via hooks (useMember, useRoles, etc.) and re-render on change — no React Query invalidations needed, no polling, no websocket logic in the component tree.
React Query still handles initial hydration and the rare request-response call. Realtime owns the "something changed somewhere else" case; React Query owns "I asked for something."
Adding an event
- Emit a new
DomainEventfrom the aggregate. - Add a matching entry to the
ServerEventunion in@orbit/shared/realtime. - Subscribe to it in
RealtimeEventPublisherand broadcast on the appropriate channel. - Handle it on the client in
applyServerEvent— TypeScript will complain until you've covered every case.
The full event catalog — every ServerEvent with its payload shape — will live under Reference → Realtime event catalog.