Skip to content

Broadcast & Presence

Beyond database changes, realtime channels carry two more primitives: Broadcast (ephemeral pub/sub between clients) and Presence (who’s currently in a channel).

Broadcast

Broadcast sends a message to everyone subscribed to a channel — no database write. It’s ideal for cursors, typing indicators, live reactions, and game state.

Send and receive (SDK)

const channel = db.channel('room-42')
// Receive
channel.on('broadcast', { event: 'cursor' }, ({ payload }) => {
renderCursor(payload.userId, payload.x, payload.y)
})
await channel.subscribe()
// Send
channel.send({
type: 'broadcast',
event: 'cursor',
payload: { userId, x: 120, y: 240 },
})
final channel = db.channel('room-42');
channel.onBroadcast(
event: 'cursor',
callback: (payload) => renderCursor(payload),
).subscribe();
channel.sendBroadcastMessage(event: 'cursor', payload: {'x': 120, 'y': 240});

Raw protocol

Inbound and outbound broadcast frames carry { "event": <name>, "payload": <any> } on a channel topic. The server relays each broadcast to the other members of the channel.

Self-delivery (broadcast.self)

By default the sender does not receive an echo of its own broadcast — only the other subscribers do. Set config.broadcast.self = true (Supabase receiveOwnBroadcasts) when the sender’s own channel should also receive the message:

const channel = db.channel('room-2', {
config: { broadcast: { self: true } },
})
channel.on('broadcast', { event: 'cursor' }, ({ payload }) => {
// With self:true this fires for the sender's OWN sends too.
})
await channel.subscribe()
channel.send({ type: 'broadcast', event: 'cursor', payload: { x: 1, y: 2 } })

self defaults to false (no self-echo) — the historical behaviour — so this is purely additive.

Server-side broadcast (REST / httpSend)

A server (or any REST caller) can push a broadcast without a WebSocket connection by POSTing to the realtime service’s broadcast endpoint — the realtime-js httpSend target. Connected clients subscribed to the topic receive it as an ordinary broadcast event.

Terminal window
curl -X POST \
'https://anvilbase.example.com/v1/<project_id>/realtime/v1/api/broadcast' \
-H 'apikey: anvilbase_service_role_<slug>_…' \
-H 'Content-Type: application/json' \
--data-raw '{
"messages": [
{ "topic": "room-2", "event": "cursor", "payload": { "x": 1, "y": 2 } }
]
}'
EndpointPOST /v1/<project_id>/realtime/v1/api/broadcast (SDK shape) or POST /v1/realtime/<project_id>/api/broadcast (native)
Authapikey: / Authorization: Bearer — the per-project anon/service key or a project JWT (verified exactly like a channel join)
Body{ "messages": [ { "topic", "event", "payload" }, … ] } (batched)
Response202 Accepted ({} body); 401 on a missing/invalid credential; 400 on a malformed body

Each message fans out on its topic so every connected subscriber of db.channel("<topic>") receives the event/payload. Use this for server-originated notifications, fan-out from a webhook handler, or pushing from a cron job.

Characteristics

  • Ephemeral — not persisted; a client that joins later won’t see past messages.
  • Fan-out — every current subscriber receives it (the sender can opt out of self-delivery via broadcast.self, default off).
  • Fast — no database round-trip.
  • Server-pushable — the REST api/broadcast endpoint above needs no WS.

Use Postgres Changes instead when you need durability or history.

Presence

Presence tracks the set of clients currently joined to a channel and synchronizes that state across all of them — perfect for “who’s online”, live avatars, and collaborative editor rosters.

Track and read (SDK)

const channel = db.channel('room-42', {
config: { presence: { key: userId } },
})
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState() // { userId: [{...meta}], ... }
renderOnlineList(state)
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => { /* … */ })
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => { /* … */ })
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({ name: 'Alice', online_at: new Date().toISOString() })
}
})
// stop appearing online
await channel.untrack()

Events

EventMeaning
syncthe full presence state changed — re-read presenceState()
joinone or more clients started tracking
leaveone or more clients stopped (disconnect or untrack)

Presence key (presence.key)

Each presence entry is identified by a key. Supply config.presence.key to control it:

const channel = db.channel('room-42', {
config: { presence: { key: deviceId } },
})

The key determines whether two connections collapse into one entry or appear as distinct presences:

  • Omitted → the entry is keyed by the user’s id (auth.uid()), so a second connection for the same user replaces the first entry. This is the default and matches the prior behaviour.
  • Set to a per-device (or otherwise unique) value → each connection appears as a distinct presence entry, so a user signed in on two devices shows up twice in presenceState(). Use this for multi-device rosters and live avatars where each session is its own dot.

Raw protocol

A client sends presence with { "event": "track" \| "untrack", "payload": {...} }. On join, the server pushes the current presence_state keyed by the channel’s presence.key (falling back to the user id). Thereafter it emits presence_diff as members come and go. Presence is backed by Phoenix’s distributed, CRDT-based presence, so it stays consistent across a clustered deployment.

Tenant isolation

Broadcast and Presence topics are scoped to your project internally. You join a channel by its logical name (db.channel("room-42")), but the realtime engine fans broadcasts out — and tracks presence — on a per-project topic derived from the verified project_id in your API key/JWT, not from the channel name you pass.

The practical guarantee: two different projects can both have a "room-42" channel and they are completely isolated — project A’s broadcasts never reach project B’s subscribers, and project A’s presence entries never appear in project B’s presenceState(). The project_id used for scoping always comes from the credential the server verified, so a client cannot reach another project’s topic by naming it. This applies to the server-side api/broadcast endpoint too: a broadcast is delivered only to subscribers of the same project as the credential it was sent with.

This is purely an internal isolation property — the wire protocol is unchanged, so realtime-js / supabase-js clients (and the AnvilBase SDKs) need no changes and behave identically for a single project. (Postgres Changes are isolated the same way, via a separate per-project change stream.)

The native @anvilbase/client TypeScript SDK implements this full surface identically to supabase-js: channel.on('broadcast', …) and channel.on('presence', …) for receiving, and channel.send({ type: 'broadcast', … }), channel.track(state), channel.untrack(), and channel.presenceState() for participating — so every example on this page runs unchanged on the owned SDK. (The other-language native SDKs are TypeScript-first here; use the matching Supabase client against the same base URL until they land.)

Combining the three

A collaborative document might use all three on one channel:

  • Presence for the live cursor roster (who’s here),
  • Broadcast for cursor movements and selections (fast, ephemeral),
  • Postgres Changes for the actual saved content (durable).
const channel = db.channel(`doc-${docId}`, { config: { presence: { key: userId } } })
channel
.on('presence', { event: 'sync' }, updateRoster)
.on('broadcast', { event: 'cursor' }, moveCursor)
.on('postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'documents', filter: `id=eq.${docId}` },
applyContent)
.subscribe(async (s) => { if (s === 'SUBSCRIBED') await channel.track({ name }) })

Tips

  • Keep broadcast payloads small and high-frequency-friendly (cursors at ~30–60 Hz are fine; throttle if needed).
  • Always untrack() / unsubscribe() on unmount.
  • Presence keys should be stable per user (or per device) so join/leave maps to the right person.

Next: Storage → Overview.