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
| Name | Meaning |
|---|---|
schema:table, e.g. public:messages | A database change feed |
anything else, e.g. room:42 | Broadcast 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
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 ofdelete. 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
| Behaviour | Detail |
|---|---|
| Server heartbeat | A 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 heartbeat | The SDK sends its own ping every 25 s and closes the socket if the pong never comes. |
| Slow consumers | A 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 reconnect | The server's own LISTEN connection reconnects with exponential backoff and full jitter, 500 ms up to 30 s, if Postgres restarts. |
| Client reconnect | Exponential 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 size | Capped 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
| Variable | Default | Effect |
|---|---|---|
REALTIME_MAX_CHANNELS | 100 | Channels one socket may hold |
REALTIME_HEARTBEAT_MS | 30000 | Server ping interval |
Failure modes
| What you see | Why | Fix |
|---|---|---|
subscribed, then nothing, ever | The table has no NOTIFY trigger | select baselyra.enable_realtime('public.your_table') |
| Works locally, dead in production | The proxy is not forwarding Upgrade | The curl test above; fix the vhost |
| Events for some rows only | RLS hid the rest, or your filter excluded them | Test the policy with the impersonation block |
The socket closes with 1008 | The token was rejected: expired, or JWT_SECRET changed | Refresh the session; the SDK reconnects with the new token |
| Events stop after a few minutes | A proxy idle timeout below the heartbeat interval | proxy_read_timeout 300s / ProxyTimeout 300 |
| Every message arrives twice in React | The channel outlived a remount | Create it inside the effect, remove it in the cleanup |
| A subscriber sees deletes it should not | Deletes are not RLS-checked | Soft-delete instead |
a socket may hold at most 100 channels | A channel per row, or a leak | One channel per table with a filter, or raise REALTIME_MAX_CHANNELS |
| The whole feed goes quiet after a database restart | The listener reconnects with backoff — up to 30 s | Wait; realtime: listening for postgres changes appears in the log when it is back |