Skip to content

Realtime

The web app and the mobile app keep a WebSocket open to the workspace on screen. Events reach everyone connected to that workspace, or only one person’s apps, whichever copy of the API they are connected to: each copy writes them to one Redis stream and reads every event from it.

The kit sends three:

Event Sent by The web app then
usage.changed The API, on every use Shows the new usage, wherever it is on screen
workspace.changed A member’s app, after a change through Better Auth Fetches the workspace and its people again
notifications.changed The API, to one person Fetches their notifications again
  1. Name it, with what it carries, in REALTIME_EVENTS in packages/contracts/src/realtime.ts. Add it to CLIENT_EVENTS too if apps may send it.

    'project.changed': z.object({ projectId: z.uuid() }),
  2. Send it from the API where the change happens. Inside database.transaction(), it goes out once the change is saved:

    this.realtime.publish(workspace.id, 'project.changed', { projectId });

    Or to one person’s open apps, on every device, and to nobody else. Nothing is kept: someone with no app open never hears it. For what they should see later, send a notification instead.

    this.realtime.publishToUser(user.id, 'export.ready', { exportId });

    Or from the web app, for an event in CLIENT_EVENTS:

    import { realtime } from '@/lib/realtime';
    realtime.send('project.changed', { projectId });
  3. Do something with it in useRealtime, in apps/web/src/features/workspaces/use-realtime.ts and apps/mobile/src/features/workspaces/use-realtime.ts, usually fetching a query again:

    case 'project.changed':
    void queryClient.invalidateQueries({ queryKey: ['workspaces', workspaceId, 'projects'] });
    break;

Send what changed, not the change: an event is a nudge to look again. One that is missed costs a refresh, never data, and nothing in an event is checked again before it is shown.

The connection is GET /api/realtime?workspaceId=<id> with Upgrade: websocket, on the API’s own address, so Caddy and the dev server pass it on as they do /api. It is let in with a session, for a member of the workspace, from a page of the app or the API’s own address, as the mobile app sends: a page elsewhere gets 403, someone who is not a member 404. It hears the workspace’s events and the person’s own. Each event names who sent it, or null for the API.

What an app sends is checked against the contracts, and each person may send 30 events every 10 seconds. A connection stays open until it is closed, even if the session ends meanwhile; the next one needs a session again.

Connect as a member with new WebSocket(url, { headers: { cookie: client.cookieHeader(), origin } }). To test two copies of the API, start two test apps with the same redisPrefix. See apps/api/test/realtime.e2e.test.ts.