Broadcast, presence and table changes
supabase's channel API in every SDK — broadcast, presence and postgres_changes with subscribe, send, track, presenceState and removeChannel: how it maps onto Sovaria channels, guarantees, limits and security.
One channel object for the three things a live screen needs: broadcast (short messages to everyone on the channel: cursors, typing, game moves), presence (who is here and each person's state) and table changes (a row was added, changed or deleted). The calls are supabase's; underneath they are Sovaria channels, so channel rules, plan limits and catch-up after reconnects all apply (Channels and connections).
const room = sovaria.channel('room-42', { config: { broadcast: { self: false, ack: false }, presence: { key: user.id } } })
room
.on('broadcast', { event: 'cursor' }, ({ payload }) => moveCursor(payload))
.on('presence', { event: 'sync' }, () => setOnline(room.presenceState()))
.on('presence', { event: 'join' }, ({ key, newPresences }) => toast(`${key} joined`))
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, () => reloadMessages())
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') await room.track({ name: user.name, online_at: Date.now() })
})
await room.send({ type: 'broadcast', event: 'cursor', payload: { x: 120, y: 48 } })
// later
await sovaria.removeChannel(room)#Broadcast
send({ type: 'broadcast', event, payload })reaches everyone subscribed to the channel;.on('broadcast', { event }, cb)receives one event name ('*'for all).- You don't get your own messages unless
config.broadcast.selfis true. Withconfig.broadcast.ack,send()waits until the server accepted the message. - Messages a plain Sovaria client publishes (
sovaria.realtime.publish) arrive as broadcasts too, with theirevent(or'message'). - Each message counts toward your plan's realtime messages and is checked against the channel's publish rule and size limit.
#Presence
track(state)shares this client's state;presenceState()gives everyone's, by presence key (config.presence.key, or the user's id).joinandleaveevents say who came and went;syncfires after any change.- Each entry carries
user_id: the signed-in user the server stamped on the message. A client can pick its presence key but can't pretend to be someone else — checkuser_idbefore you trust an entry. - Only the server sends the full list (
presence_state) and the changes (presence_diff). A client may publish onlytrackanduntrack; any other presence message is refused (publish_rejected, or 400 on the publish API), and the SDKs ignore a presence list that another client sent. - Presence is kept by the server. A client that joins is sent everyone's current state at once. Everyone on the channel is told when someone tracks, untracks or disconnects. If a connection drops without saying goodbye (a closed laptop, a lost network), its entry is removed within 90 seconds and the others are told.
- A state can be up to 4 KB, and a channel can have up to 1,000 people present (limits set by Sovaria). Past either limit,
trackis refused withpresence_refused. - Each
track(and its refresh) is a realtime message. For a plain "who is connected" list without state,sovaria.realtime.presence(channel)costs nothing (Presence).
Presence sent through the publish API
Current SDKs send track and untrack on the channel's own connection. Older ones sent them through the publish API (POST /api/v1/rt/stream/publish/{channel}): the JavaScript SDK up to 0.9.2 when config.broadcast.ack was on, and the Python SDK up to 0.11.2 always. Sovaria keeps that presence too, with the same rules:
- Signed-in users only. A publish with just the project key, the service role key or an internal caller is still delivered, but no presence is kept for it.
- Your own entry only. The entry is stored for the signed-in caller (one per tab, by the message's
ref) anduser_idis stamped by the server, so nobody can replace or remove another user's presence. - The same channel rules. Publishing needs the PUBLISH rule; keeping a
trackalso needs SUBSCRIBE, as a connection would.untrackneeds only PUBLISH. - The same limits. 4 KB of state and 1,000 people per channel, plus at most 10 entries per user per channel (one per open tab). These limits are set by Sovaria, the same for every project.
- 90-second expiry. There is no connection to watch, so an entry stays for 90 seconds after its last
track(those SDKs re-send it every 30 seconds), then leaves and everyone is told, as for a dropped connection.untrackremoves it at once. - Refusals don't fail the publish. The message is still delivered and the answer is
200; when the presence couldn't be kept, the answer has apresence_refusedfield saying why (state too large, channel full, too many tabs, or no SUBSCRIBE rule).
{ "channel": "room-1", "published": true, "stream_seq": 42,
"presence_refused": "At most 10 presence entries per user on a channel" }#Table changes (postgres_changes)
.on('postgres_changes', { event: 'INSERT' | 'UPDATE' | 'DELETE' | '*', schema: 'public', table }, cb) listens on the table's change events — turn them on for the table first (Database change events). The callback gets supabase's payload shape:
{ "schema": "public", "table": "messages", "eventType": "INSERT", "commit_timestamp": "2026-10-31T09:12:44.512Z",
"new": { "id": 42, "room_id": 7, "body": "hi" }, "old": {}, "errors": null, "row_count": 1 }new is the row as this user could read it: each subscriber gets only the rows your access policies and row-level security let it see. A change to a row it can't see isn't sent to it at all. A delete carries the row's primary key in old only for tables without row-level security. filter ('room_id=eq.42') is applied on the server, so only matching rows are sent. Details: Database change events.
#Subscribing and leaving
| Status | When |
|---|---|
SUBSCRIBED | The server admitted this user to the channel (then presence and broadcast work). The SDKs (JS 0.10.5+, Python 0.12.3+) wait for that confirmation, so a refused channel goes straight to CHANNEL_ERROR and never shows SUBSCRIBED first. |
CHANNEL_ERROR | The connection was refused: no rule lets this user subscribe, or the session ended. |
TIMED_OUT | No connection within 10 seconds (pass another timeout to subscribe). |
CLOSED | After unsubscribe() / removeChannel(), or the connection closed for good. |
Under one channel object there is one connection for the channel and one per table listened to; they reconnect with backoff and catch up on missed broadcasts by themselves. removeAllChannels() closes everything (on sign-out, for example).
#Rules and limits
- Subscribing needs the channel's SUBSCRIBE rule; sending and tracking need its PUBLISH rule (Channels and connections). Table changes need a SUBSCRIBE rule on
db.table.<table>, or a read rule that names the table (an explicit ALLOW). - Plan limits apply per message and connection: messages per month, concurrent connections, message size and events per second (Realtime settings and limits).
#Why a connection was refused
A refused connection is opened and then closed at once with a code and a reason, so your app can tell why. The SDKs stop retrying on these codes and report a typed error (RealtimeClosedError in JavaScript and Python, RealtimeClosedException in Dart, SovariaError.realtimeClosed in Swift, SovariaException.realtimeClosed in Kotlin) with the code and the reason. Other closes (a network drop, 1011, 1013 busy) reconnect with backoff.
| Code | Meaning | Error code |
|---|---|---|
4401 | Sign in again: no session, or the ticket expired or was already used. | UNAUTHENTICATED |
4403 | No rule lets this user subscribe to the channel, or access was revoked (reason CHANNEL_DENIED: …). | PERMISSION_DENIED |
4408 | A malformed request, such as a bad filter. | BAD_REQUEST (Dart, Swift, Kotlin: INVALID_ARGUMENT) |
4429 | A rate or connection limit: plan, project or per-user (reason PLAN_LIMIT_REACHED: … and so on). | RATE_LIMITED |