Skip to content

Realtime

WebSocket subscriptions with topic-based fan-out for collections, specific records, and the wildcard topic.

A single WebSocket endpoint at /realtime carries every subscription. Topics are strings; subscribe to as many as you like.

const ws = new WebSocket("ws://localhost:8091/realtime");
ws.onopen = () => {
// optional: authenticate first so view rules see you
ws.send(JSON.stringify({ type: "auth", token: userJwt }));
ws.send(JSON.stringify({ type: "subscribe", topics: ["posts"] }));
};
ws.onmessage = (e) => {
const event = JSON.parse(e.data);
console.log(event);
};

The first message you receive after connection is { type: "connected" }.

TopicReceives
<collection>every event for that collection
<collection>/<id>events for one specific record
<collection>.create / .update / .deleteonly that event type for the collection
*every event everywhere
*.create / .update / .deletethat event type globally

A singleton collection’s record is announced like any other: subscribe to <collection> (or <collection>.update) to get its updates, filtered by its view_rule alone, since a singleton has no list. It never has create or delete events. (Before 0.26.2 the collection topics also applied the unused list rule, so only admins got them; <collection>/<id> worked for everyone.)

Broadcasts fan out to all matching topics — a delete on posts/abc notifies subscribers of posts, posts/abc, and * simultaneously. Per-ws deduplication means a client subscribed to multiple matching topics gets each event once, not three times.

Wildcard variants accepted on input: posts.* and posts/* both normalise to posts on subscribe + unsubscribe. So this works:

{ "type": "subscribe", "topics": ["posts.*"] } // → stored as "posts"
{ "type": "unsubscribe", "topics": ["posts"] } // removes the same key

Server replies with { "type": "subscribed", "topics": [...] } and { "type": "unsubscribed", "topics": [...] } carrying the canonical topics it actually applied — useful for debugging when the form you sent doesn’t match the form you expected.

{ "type": "create", "collection": "posts", "record": { ...full record } }
{ "type": "update", "collection": "posts", "record": { ...full record } }
{ "type": "delete", "collection": "posts", "id": "abc" }

Records are the same shape as the REST API returns (with id, created, updated, all field values). Deletes carry only the id since the record is gone.

// Subscribe / unsubscribe
{ "type": "subscribe", "topics": ["posts", "posts/abc", "*"] }
{ "type": "unsubscribe", "topics": ["posts/abc"] }
// Refresh credentials on a live connection (optional)
{ "type": "auth", "token": "<new-jwt>" }

collections is accepted as an alias for topics for backwards compatibility.

Optional. Send a user or admin JWT as a message at any time after connecting: { "type": "auth", "token": "<jwt>" }. Tokens in the URL are not accepted (they leak into logs and Referer headers).

Browser connections must come from an origin in security.allowed_origins; clients that send no Origin header (servers, the SDK under Node / Bun) are accepted and authenticate by token.

The auth context is stored per-connection and is consulted at broadcast time to enforce each collection’s view_rule (and list_rule, on collection-wide topics) per subscriber.

Every record event (create / update / delete / cascade) is filtered for each subscriber individually. A <collection>/<id> topic needs the collection’s view_rule; collection-wide topics (<collection>, *, and the .create / .update / .delete forms) need both its view_rule and its list_rule. Each rule is applied like this:

  • Admins always receive the event (bypass).
  • null (public) → every subscriber receives.
  • "" (admin-only) → non-admin subscribers are silently skipped.
  • Expression rule → evaluated per-subscriber against the record. Failing subscribers are skipped silently — no error message, no leak that the record exists.

Delete events evaluate against the just-deleted record snapshot so a rule like owner = @request.auth.id still works (the row is already gone in the DB by broadcast time).

// Example: a rule like `owner = @request.auth.id`
// Connection A is signed in as user u1, owner of post p7
// Connection B is signed in as user u2, NOT the owner
// Connection C is admin
await fetch("/api/v1/posts/p7", { method: "PATCH", body: JSON.stringify({ title: "edited" }) });
// → A and C receive `update posts/p7`
// → B receives nothing

When a delete cascades (relation field with cascade: "cascade" or "setNull"), each affected record fires its own broadcast event. So a single DELETE /api/v1/users/alice may emit:

  • delete posts/p1, delete posts/p2 (cascade)
  • update comments/c5 with author = null (setNull)

Each event reaches its appropriate subscribers (collection / record / wildcard).

Server-side JS hooks bypass the records API and so don’t trigger realtime events when they call helpers.find etc. If your hook needs to broadcast, use helpers.find/query to read but call the records API methods (or expose a custom route) to write.

  • Per-process subscription map. No inter-worker pub/sub yet. In single- process mode (the default) this is invisible. In cluster mode each worker keeps its own map, so a client connected to worker A will not see writes that landed on worker B. Either pin the realtime path to one worker via the reverse proxy (e.g. nginx ip_hash on the /realtime location) or stay single-process — Bun’s WebSocket implementation sustains thousands of open connections per process, so the cluster is a CPU optimization, not a connection-count one. Cluster mode is deprecated and won’t get cross-worker fan-out (roadmap); read worker threads, and several servers on one Postgres database, deliver every change to every subscriber.
  • No durable replay. An event sent while a client was disconnected isn’t delivered after it reconnects. Refetch what you show after a reconnect.
  • SSE fallback available — GET /api/v1/realtime (see the API page) for clients that can’t open WebSockets. Same per-worker constraint applies.
  • Connection cap — at most 10,000 realtime connections (WebSocket + SSE) per process and 100 per client address; see the API page.