Skip to content

Realtime (WebSocket)

Wire-level reference for the /realtime WebSocket endpoint.

Single endpoint. Topic-based fan-out. See Realtime concepts for the high-level overview.

ws://<host>/realtime

Authenticate with an auth message after connecting (see Auth). Tokens in the URL are not accepted (they leak into logs and Referer).

Origin: a browser connection must come from this server’s own origin or one listed in security.allowed_origins, otherwise the server replies { "type": "error", "reason": "origin_not_allowed" } and closes. Clients that send no Origin header (servers, scripts, the SDK under Node / Bun) are accepted; they still need a token to see protected data. The SSE fallback applies the same rule (403 for a disallowed origin).

First message after connect:

{ "type": "connected" }

Per-event messages:

{ "type": "create", "collection": "<name>", "record": { ...full RecordWithMeta } }
{ "type": "update", "collection": "<name>", "record": { ...full RecordWithMeta } }
{ "type": "delete", "collection": "<name>", "id": "<record_id>" }

record matches the REST API’s record shape exactly.

{ "type": "subscribe", "topics": ["<topic>", ...] }
{ "type": "unsubscribe", "topics": ["<topic>", ...] }
{ "type": "auth", "token": "<jwt>" }

Topics:

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

collections accepted as an alias for topics (backwards compat).

A single broadcast to posts/abc reaches subscribers of:

  • posts
  • posts/abc
  • *

A client subscribed to multiple matching topics gets the event once (per-ws dedup).

Attach a user/admin JWT with an auth message, right after connected or at any point later:

const ws = new WebSocket("ws://host/realtime");
ws.onopen = () => ws.send(JSON.stringify({ type: "auth", token: jwt }));

Auth context is stored per-connection and survives reconnect cycles via the client’s reconnect logic.

Every event is filtered per subscriber. 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. A singleton’s collection topics follow its view_rule alone (0.26.2+). Each rule is applied like this:

RuleBehavior
null (public)Every subscriber receives
"" (admin only)Only admin connections receive
Expression (e.g. owner = @request.auth.id)Evaluated per-subscriber against the record. Non-matching subscribers are skipped silently.

Admins always receive (bypass).

Delete events evaluate against the just-deleted record snapshot so rules that reference per-record fields (like owner = @request.auth.id) still work.

Filtering is silent — non-matching clients see no event, no error. A client without permission cannot infer existence of records they’re not allowed to read.

client → ws connect
server → { type: "connected" }
client → { type: "auth", token: "<jwt>" } ← optional, any time
client → { type: "subscribe", topics: [...] }
... events stream ...
client → { type: "unsubscribe", topics: [...] } ← optional
client closes connection (or server closes on idle)

Close handler removes the connection from every subscription set automatically.

For clients that can’t open WebSockets (locked-down proxies, runtimes without a WS API), an HTTP-only fallback exposes the same fan-out via Server-Sent Events.

GET /api/v1/realtime
Accept: text/event-stream

The server responds with Content-Type: text/event-stream and pushes:

event: connect
data: {"type":"connected","clientId":"<uuid>"}
event: message
data: {"type":"create","collection":"posts","record":{...}}
event: message
data: {"type":"delete","collection":"posts","id":"abc"}
: ping

The first frame carries a server-minted clientId. Use it to manage subscriptions over a side-channel HTTP request. : ping lines are SSE comments — clients ignore them; they keep idle connections alive through proxies (sent every 30 seconds).

POST /api/v1/realtime
{ "clientId": "<from connect frame>",
"topics": ["posts", "comments/abc"],
"token": "<optional jwt>" }
→ { "data": { "clientId": "...", "topics": [...] } }

subscriptions and collections are accepted as aliases for topics. Calling this replaces the client’s full topic list (PUT semantics). token attaches/refreshes the per-connection auth used for view_rule filtering — same JWTs the WS path uses.

404 if the clientId was never opened or has been torn down. Once a token has been attached, further calls for that clientId (and the teardown below) need a token for the same user — in the body or an Authorization: Bearer header — or they answer 403.

DELETE /api/v1/realtime/<clientId>
→ { "data": null }

Idempotent — unknown clientIds also return 200. A client that has authenticated needs that user’s token (Authorization: Bearer).

  • Same wildcard / per-record / per-collection topic forms.
  • Same view_rule / list_rule filtering on every event.
  • Same admin-bypass.
  • Heartbeat every 30s.
  • A client that stops reading, or can’t keep up, is disconnected once about 1 MB of events is waiting for it (WebSocket since 0.24.1). Reconnect and refetch, as after any disconnect.
  • At most 100 topics per connection; extra topics are left out of the subscribed reply.
  • At most 10,000 open realtime connections per server process; past that the WebSocket upgrade is refused with 503 and Retry-After: 5 (before 0.26.5: an { "type": "error", "reason": "too_many_connections" } frame and a close; one that slips in during a race still gets that), and SSE gets 503. Back off before reconnecting, as the SDK does. The same applies past 100 connections from one client address (an IPv6 client counts by its /64). Behind a reverse proxy, set security.trusted_proxies so the real client address is used; until then, connections from a loopback peer (a proxy on the same host) are only held to the per-process limit. A proxy on another host or in a container network must be listed, or all its clients share one 100-connection allowance.
  • WebSocket messages over 64 KB close the connection.
  • { "type": "ping" } is answered with { "type": "pong" }.
  • A connection’s token is re-checked every minute and at its expiry. When it no longer holds (signed out, password reset, expired), the connection gets { "type": "auth_expired" } and continues as anonymous. Send a fresh token ({ "type": "auth" } or the SSE POST) to keep receiving protected events.
// Browser EventSource example
const es = new EventSource("/api/v1/realtime");
let clientId = null;
es.addEventListener("connect", (ev) => {
({ clientId } = JSON.parse(ev.data));
fetch("/api/v1/realtime", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${jwt}` },
body: JSON.stringify({ clientId, topics: ["posts"], token: jwt }),
});
});
es.addEventListener("message", (ev) => {
const event = JSON.parse(ev.data);
// event.type === "create" | "update" | "delete"
});
  • Broadcast reaches every subscriber of one server, and of every server sharing one Postgres database. Under the deprecated vaultbase cluster it stays per worker.
  • No durable replay — events sent while a client was disconnected aren’t delivered later. Refetch after a reconnect.
  • Hooks bypass realtime — helpers.find/query reads but doesn’t fire events.