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 (an IPv6 client counts by its /64). 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.