Realtime (WebSocket)
Wire-level reference for the /realtime WebSocket endpoint.
Single endpoint. Topic-based fan-out. See Realtime concepts for the high-level overview.
Endpoint
Section titled “Endpoint”ws://<host>/realtimeAuthenticate 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).
Server → client
Section titled “Server → client”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.
Client → server
Section titled “Client → server”{ "type": "subscribe", "topics": ["<topic>", ...] }{ "type": "unsubscribe", "topics": ["<topic>", ...] }{ "type": "auth", "token": "<jwt>" }Topics:
| Form | Receives |
|---|---|
<collection> | every event in that collection |
<collection>/<id> | events for one specific record |
<collection>.create / .update / .delete | only that event type for the collection |
* | every event in every collection |
*.create / .update / .delete | that event type in every collection |
collections accepted as an alias for topics (backwards compat).
Fan-out
Section titled “Fan-out”A single broadcast to posts/abc reaches subscribers of:
postsposts/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.
Per-record rule filtering
Section titled “Per-record rule filtering”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:
| Rule | Behavior |
|---|---|
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.
Lifecycle
Section titled “Lifecycle”client → ws connectserver → { type: "connected" }
client → { type: "auth", token: "<jwt>" } ← optional, any timeclient → { type: "subscribe", topics: [...] }... events stream ...client → { type: "unsubscribe", topics: [...] } ← optionalclient closes connection (or server closes on idle)Close handler removes the connection from every subscription set automatically.
SSE fallback
Section titled “SSE fallback”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.
Open the stream
Section titled “Open the stream”GET /api/v1/realtimeAccept: text/event-streamThe server responds with Content-Type: text/event-stream and pushes:
event: connectdata: {"type":"connected","clientId":"<uuid>"}
event: messagedata: {"type":"create","collection":"posts","record":{...}}
event: messagedata: {"type":"delete","collection":"posts","id":"abc"}
: pingThe 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).
Set subscriptions
Section titled “Set subscriptions”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.
Tear down
Section titled “Tear down”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).
Behavior matches WebSocket
Section titled “Behavior matches WebSocket”- Same wildcard / per-record / per-collection topic forms.
- Same
view_rule/list_rulefiltering on every event. - Same admin-bypass.
- Heartbeat every 30s.
Limits and token expiry (both transports)
Section titled “Limits and token expiry (both transports)”- 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
subscribedreply. - At most 10,000 open realtime connections per server process; past that the
WebSocket upgrade is refused with
503andRetry-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 gets503. Back off before reconnecting, as the SDK does. The same applies past 100 connections from one client address. Behind a reverse proxy, setsecurity.trusted_proxiesso 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 SSEPOST) to keep receiving protected events.
// Browser EventSource exampleconst 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"});Limits
Section titled “Limits”- Broadcast reaches every subscriber of one server, and of every server
sharing one Postgres database. Under the deprecated
vaultbase clusterit 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/queryreads but doesn’t fire events.