Baselyra Docs

Realtime

One WebSocket endpoint carries three independent things: row-level change feeds from tables you opt in, ephemeral broadcast between clients, and presence. Change feeds are authorised the hard way — each changed row is re-read as the subscriber's own Postgres role before delivery, so a policy that hides the row hides the event.

Connecting

ws://localhost:3130/realtime/v1?apikey=<jwt>
wss://api.example.com/realtime/v1?apikey=<jwt>

Browsers cannot set headers on a WebSocket handshake, which is why the query parameter exists. Non-browser clients may send Authorization: Bearer … or apikey: headers instead, and a header wins when both are present. A connection with no credentials at all is legitimate and runs as anon.

Enabling a table

Change feeds are opt-in per table, because a NOTIFY trigger on a hot table nobody is watching is pure cost.

select baselyra.enable_realtime('public.messages');
select baselyra.disable_realtime('public.messages');

Or Studio → Realtime, which lists enabled tables, the live socket count and an event inspector. This attaches an AFTER INSERT OR UPDATE OR DELETE trigger that emits on LISTEN baselyra_realtime. The function is security definer and executable only by service_role, so a project user cannot attach triggers to arbitrary tables.

Channels

NameMeaning
schema:table, e.g. public:messagesA database change feed
anything else, e.g. room:42Broadcast and presence only

Only a name matching identifier:identifier produces postgres_changes. Everything else is a pure in-memory channel. A socket may hold up to REALTIME_MAX_CHANNELS channels, 100 by default.

How authorisation works

insert into messages room_id = 42 AFTER trigger pg_notify('baselyra_realtime') the payload is the key, not the row {schema, table, event, pk, ts} once per distinct subscriber identity — SELECT … WHERE pk = $1, inside asRole() Ada — in room 42 role authenticated policy admits → delivered Grace — not in room 42 role authenticated policy denies → dropped an anonymous socket role anon policy denies → dropped type: postgres_changes carrying the row that was re-read A decision is cached for three seconds per socket and row, so a busy table is not one query per subscriber per change.
The fan-out, and the re-check inside it. The notification carries the primary key, never the row body — the row that ships is the one that subscriber's own role was allowed to read.

There is no policy evaluation in JavaScript anywhere in the realtime path. A subscription can never show more than a select would.

Visibility decisions are cached for three seconds per socket identity and row, so a busy table does not become one query per subscriber per change — short enough that a revoked grant stops mattering quickly. A re-read that fails is treated as "not visible": leaking on error would defeat the point of re-reading at all.

The one exception: DELETE

Two ways around it, if a table's contents are confidential:

  • Soft delete. update … set deleted_at = now() instead of delete. The update goes through the normal re-read, and your read policy can exclude it.
  • Tombstones. Have a trigger write the deleted row's key to an RLS-protected table and enable realtime on that instead.

Inserts and updates have no such caveat.

The wire protocol

If you are using the JS client, skip to the client section — it does all of this for you.

Client → server:

{"type":"subscribe","channel":"public:messages","filter":"room_id=eq.42"}
{"type":"unsubscribe","channel":"public:messages"}
{"type":"broadcast","channel":"room:42","event":"typing","payload":{"name":"Ada"}}
{"type":"presence","channel":"room:42","state":{"name":"Ada"}}
{"type":"ping"}

Server → client:

{"type":"subscribed","channel":"public:messages"}
{"type":"postgres_changes","channel":"public:messages","event":"INSERT",
 "new":{"id":901,"room_id":42,"author":"6c1f…","body":"hello","created_at":"2026-08-24T12:00:01.004Z"},
 "old":null}
{"type":"broadcast","channel":"room:42","event":"typing","payload":{"name":"Ada"}}
{"type":"presence_state","channel":"room:42","state":{"e7c9…":{"name":"Ada"}}}
{"type":"error","message":"subscribe to room:42 before broadcasting on it"}
{"type":"pong"}

Re-subscribing to a channel replaces its filter rather than erroring, so a client can change a filter without a round trip through unsubscribe. A broadcast is not echoed to its sender, and you must be subscribed to a channel to broadcast on it or track presence there — which is what makes REALTIME_MAX_CHANNELS the real cap on how many channels one socket can reach.

An UPDATE carries both: new is the row as re-read under your role, and old is the trigger's copy of the row before the change.

Filters

room_id=eq.42
status=in.(open,pending)
status=in.(open,"needs review")
deleted_at=is.null

Operators: eq, neq, gt, gte, lt, lte, in, is. One filter per subscription, on one unquoted column. is accepts null, true, false or unknown. Inside an in list, a value containing a comma must be double-quoted — silently splitting one would drop rows a client expected to match.

Large rows

pg_notify hard-fails above 8000 bytes, and that failure would abort the writing transaction — your insert would fail because someone was listening. So a payload over about 7.5 KB degrades instead: the notification is sent with the primary key and truncated: true in place of the row bodies. Since the server re-reads the row anyway for INSERT and UPDATE, subscribers still get the full current row. A truncated DELETE is dropped, because there is nothing left to read and nothing to send.

Broadcast and presence

Neither touches the database, so neither is subject to RLS — a client that can join a channel can send on it and see who else is there.

Presence state is capped at 4096 bytes per member. Joining, updating or leaving re-broadcasts presence_state to the whole channel, and a disconnect removes the member automatically. Members are keyed by connection id, so one person with two tabs is two members.

Connection management

BehaviourDetail
Server heartbeatA ping every REALTIME_HEARTBEAT_MS (30 s). A socket that misses two consecutive pongs is terminated — after a laptop sleeps a connection is dead but still reports itself OPEN, and without this the app receives nothing, forever, while looking perfectly healthy.
Client heartbeatThe SDK sends its own ping every 25 s and closes the socket if the pong never comes.
Slow consumersA socket whose send buffer exceeds 1 MB is dropped. It is not going to catch up on a busy table, and buffering for it costs the whole process memory.
Listener reconnectThe server's own LISTEN connection reconnects with exponential backoff and full jitter, 500 ms up to 30 s, if Postgres restarts.
Client reconnectExponential backoff with jitter up to 30 s, then every channel is re-subscribed and queued frames are flushed. Up to 200 frames are queued while offline.
Frame sizeCapped at 1 MB.

In the client

const channel = bl.channel('room:' + roomId);

channel
  .on('postgres_changes',
      { event: 'INSERT', schema: 'public', table: 'messages', filter: 'room_id=eq.' + roomId },
      ({ new: row }) => append(row))
  .on('broadcast', { event: 'typing' }, ({ payload }) => showTyping(payload))
  .on('presence', { event: 'sync' }, ({ state }) => setHere(Object.values(state)));

const status = await channel.subscribe();   // SUBSCRIBED | CHANNEL_ERROR | TIMED_OUT | CLOSED
if (status === 'SUBSCRIBED') await channel.track({ name: 'Ada' });

await channel.send({ type: 'broadcast', event: 'typing', payload: { name: 'Ada' } });
await bl.removeChannel(channel);

One socket carries every channel. A channel's own name gets broadcast and presence; each postgres_changes binding additionally joins the server channel named schema:table.

// Surface protocol errors — a bad filter, a channel limit — instead of losing them.
const off = bl.realtime.onError((message) => console.warn('realtime:', message));

Behind a reverse proxy

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

location / {
    proxy_pass http://127.0.0.1:3130;
    proxy_http_version 1.1;                    # 1.0 cannot upgrade
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_read_timeout 300s;                   # or idle sockets are cut
    proxy_buffering off;
}
RewriteEngine On
RewriteCond %{HTTP:Upgrade} =websocket [NC]
RewriteRule ^/?(.*) ws://127.0.0.1:3130/$1 [P,L]

ProxyPass        / http://127.0.0.1:3130/
ProxyPassReverse / http://127.0.0.1:3130/
ProxyTimeout 300

The rewrite must come before the catch-all ProxyPass, and the modules must be loaded — nginx builds proxy_wstunnel in; Apache needs a2enmod proxy_wstunnel rewrite. Both full vhosts are in deploy/.

Check it from the command line. A 101 is the whole test:

curl -i -N \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  "https://api.example.com/realtime/v1?apikey=$ANON_KEY"
HTTP/1.1 101 Switching Protocols
upgrade: websocket
connection: Upgrade

HTTP/1.1 101 Switching Protocols means the path is clear. A 200 with HTML is the proxy answering instead of upgrading.

Configuration

VariableDefaultEffect
REALTIME_MAX_CHANNELS100Channels one socket may hold
REALTIME_HEARTBEAT_MS30000Server ping interval

Failure modes

What you seeWhyFix
subscribed, then nothing, everThe table has no NOTIFY triggerselect baselyra.enable_realtime('public.your_table')
Works locally, dead in productionThe proxy is not forwarding UpgradeThe curl test above; fix the vhost
Events for some rows onlyRLS hid the rest, or your filter excluded themTest the policy with the impersonation block
The socket closes with 1008The token was rejected: expired, or JWT_SECRET changedRefresh the session; the SDK reconnects with the new token
Events stop after a few minutesA proxy idle timeout below the heartbeat intervalproxy_read_timeout 300s / ProxyTimeout 300
Every message arrives twice in ReactThe channel outlived a remountCreate it inside the effect, remove it in the cleanup
A subscriber sees deletes it should notDeletes are not RLS-checkedSoft-delete instead
a socket may hold at most 100 channelsA channel per row, or a leakOne channel per table with a filter, or raise REALTIME_MAX_CHANNELS
The whole feed goes quiet after a database restartThe listener reconnects with backoff — up to 30 sWait; realtime: listening for postgres changes appears in the log when it is back

Edit this page Report a problem

Esc
navigate open Esc close