Docs

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.

Updated Nov 3, 2026

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.self is true. With config.broadcast.ack, send() waits until the server accepted the message.
  • Messages a plain Sovaria client publishes (sovaria.realtime.publish) arrive as broadcasts too, with their event (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). join and leave events say who came and went; sync fires 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 — check user_id before you trust an entry.
  • Only the server sends the full list (presence_state) and the changes (presence_diff). A client may publish only track and untrack; 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, track is refused with presence_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) and user_id is 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 track also needs SUBSCRIBE, as a connection would. untrack needs 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. untrack removes 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 a presence_refused field 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

StatusWhen
SUBSCRIBEDThe 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_ERRORThe connection was refused: no rule lets this user subscribe, or the session ended.
TIMED_OUTNo connection within 10 seconds (pass another timeout to subscribe).
CLOSEDAfter 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.

CodeMeaningError code
4401Sign in again: no session, or the ticket expired or was already used.UNAUTHENTICATED
4403No rule lets this user subscribe to the channel, or access was revoked (reason CHANNEL_DENIED: …).PERMISSION_DENIED
4408A malformed request, such as a bad filter.BAD_REQUEST (Dart, Swift, Kotlin: INVALID_ARGUMENT)
4429A rate or connection limit: plan, project or per-user (reason PLAN_LIMIT_REACHED: … and so on).RATE_LIMITED