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.
Connecting
Section titled “Connecting”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" }.
Topics
Section titled “Topics”| Topic | Receives |
|---|---|
<collection> | every event for that collection |
<collection>/<id> | events for one specific record |
<collection>.create / .update / .delete | only that event type for the collection |
* | every event everywhere |
*.create / .update / .delete | that 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 keyServer 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.
Event shape
Section titled “Event shape”{ "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.
Client messages
Section titled “Client messages”// 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.
Authentication
Section titled “Authentication”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.
Per-record rule filtering
Section titled “Per-record rule filtering”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 nothingCascade events
Section titled “Cascade events”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/c5withauthor = null(setNull)
Each event reaches its appropriate subscribers (collection / record / wildcard).
Hooks don’t broadcast
Section titled “Hooks don’t broadcast”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.
Limits
Section titled “Limits”- 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_hashon the/realtimelocation) 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.