Realtime
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");// or with auth:const ws = new WebSocket(`ws://localhost:8091/realtime?token=${userJwt}`);
ws.onopen = () => { 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 |
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. Pass a user or admin JWT in two ways:
- On connect as a query param:
wss://host/realtime?token=<jwt> - Mid-connection via
{ "type": "auth", "token": "<jwt>" }
The auth context is stored per-connection and is consulted at broadcast
time to enforce each collection’s view_rule per subscriber.
Per-record rule filtering
Section titled “Per-record rule filtering”Every record event (create / update / delete / cascade) is filtered against
the collection’s view_rule for each subscriber individually:
- Admins always receive the event (bypass).
view_rule = null(public) → every subscriber receives.view_rule = ""(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. Cross-worker fan-out is on the roadmap. - 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 — bound by Bun’s WebSocket implementation (~thousands per process by default).