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')
// Receivechannel.on('broadcast', { event: 'cursor' }, ({ payload }) => { renderCursor(payload.userId, payload.x, payload.y)})
await channel.subscribe()
// Sendchannel.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.
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 } } ] }'| Endpoint | POST /v1/<project_id>/realtime/v1/api/broadcast (SDK shape) or POST /v1/realtime/<project_id>/api/broadcast (native) |
| Auth | apikey: / Authorization: Bearer — the per-project anon/service key or a project JWT (verified exactly like a channel join) |
| Body | { "messages": [ { "topic", "event", "payload" }, … ] } (batched) |
| Response | 202 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/broadcastendpoint 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 onlineawait channel.untrack()Events
| Event | Meaning |
|---|---|
sync | the full presence state changed — re-read presenceState() |
join | one or more clients started tracking |
leave | one 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.