Realtime
Subscribe to record changes over WebSocket or SSE.
Updated
Railbase pushes record changes to subscribed clients in real time over Server-Sent Events (the generated SDK path) or WebSocket. Subscriptions respect the same access rules as the REST API — you only receive events for records you could read.
Connect and subscribe
The WebSocket endpoint is GET /api/realtime/ws (subprotocol railbase.v1).
Subscribe by sending a JSON frame with the topics you care about:
{ "action": "subscribe", "topics": ["posts/*", "posts/abc123"] }
You can subscribe / unsubscribe at any time without reconnecting.
For SSE, open GET /api/realtime?topics=posts/*,orders/create instead — the
topics ride in the query string. The endpoint also speaks the PocketBase-SDK
dialect (a PB_CONNECT hello with a clientId, then POST /api/realtime to
update that client's subscriptions). The generated TypeScript SDK uses an
authenticated fetch stream rather than the browser EventSource constructor so
it can send the bearer token in headers; raw EventSource clients can use the
documented query-token fallback where compatibility mode permits it.
Topics
Topics are <collection>/<verb> or <collection>/<recordId>:
| Topic | Matches |
|---|---|
posts/* |
every create/update/delete on posts |
posts/create |
only new posts |
posts/abc123 |
changes to one record |
Verbs are create, update, and delete.
Events
The server pushes one frame per matching record event; data carries the verb
and the record:
{
"event": "posts/create",
"id": "<event_id>",
"data": { "action": "create", "record": { "id": "abc123", "title": "…" } }
}
plus control frames (railbase.subscribed, pong, error). Over SSE the same
triple maps onto the event: / id: / data: frame fields.
Note
Each record event is filtered by the collection's View rule, evaluated per
subscriber against that record — the same gate the REST get/list read path
applies — so realtime can't leak a record a user couldn't read over REST. It is
fail-closed: an unknown collection or an unparseable rule denies delivery, and
an empty View rule means "locked" (no one receives it). The check runs on both
the live stream and on resume replay. Record payloads are also projected to the
read-surfaced columns before they go on the wire, so internal columns (password
hashes, token keys) never reach a subscriber. Subscribe-time filter: /
expand: are not supported yet; subscribe to a topic and read details over REST
if you need expansion.
From the SDK
The generated TypeScript client wraps the SSE stream as a typed
async iterator — iterate with for await, and abort to stop:
const ctrl = new AbortController();
for await (const ev of rb.realtime.subscribe<Posts>({
topics: ["posts/*"],
signal: ctrl.signal,
})) {
console.log(ev.id, ev.topic, ev.data);
}
// later: ctrl.abort()
Each ev carries id (the monotonic broker event id — pass the last one seen as
since to resume after a drop), topic (the matched topic), and data (the
decoded payload). Heartbeats and the internal control frames — the railbase.*
events and the PB_CONNECT handshake the server sends first in the default
PocketBase-compatible (strict) mode — are filtered out, so the loop yields real
topic events only.
Publishing your own topics
Server-side code can publish domain events on the bus:
app.Realtime().Publish("orders.shipped", payload, tenantID)
Subscribers receive them the same way as record events. Data-resident plugins
publish a domain topic through $app.emit(topic, payload) (consume with
$app.onEvent); $app.realtime().publish is the separate, narrower binding for
record-shaped CRUD events, not arbitrary topics.