Broadcast and Presence Channels
Beyond Database Changes
While Postgres Changes syncs database state, many realtime features need more:
- Broadcast: Send ephemeral messages to connected clients
- Presence: Track who's online and their state
These don't require database writes and are perfect for interactive features.
Broadcast: Ephemeral Messaging
Broadcast sends messages to all clients subscribed to a channel—without touching the database.
How Broadcast Works
┌─────────────────────────────────────────────────────────────┐
│ Broadcast Flow │
│ │
│ Client A (sends) │
│ │ │
│ │ 1. Send message to channel │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Realtime Server │ │
│ └────────┬────────┘ │
│ │ │
│ │ 2. Forward to all subscribers │
│ │ │
│ ┌────┴────┬─────────┐ │
│ ▼ ▼ ▼ │
│ Client B Client C Client D │
│ (receives) (receives) (receives) │
│ │
└─────────────────────────────────────────────────────────────┘
Basic Broadcast Example
// Create a channel
const channel = supabase.channel('game-room-123')
// Listen for broadcast events
channel.on('broadcast', { event: 'game_action' }, (payload) => {
console.log('Received:', payload)
})
// Subscribe to the channel
await channel.subscribe()
// Send a broadcast message
channel.send({
type: 'broadcast',
event: 'game_action',
payload: {
player: 'alice',
action: 'move',
position: { x: 10, y: 20 }
}
})
Use Cases for Broadcast
| Feature | Broadcast Event |
|---|---|
| Cursor position | { type: 'cursor', x, y, userId } |
| Typing indicator | { type: 'typing', userId, isTyping } |
| Game actions | { type: 'move', playerId, action } |
| Reactions | { type: 'reaction', emoji, userId } |
| Notifications | { type: 'alert', message } |
Broadcast Options
Self-Receive
By default, senders don't receive their own messages:
const channel = supabase.channel('room', {
config: {
broadcast: {
self: true // Receive your own broadcasts
}
}
})
Acknowledgment
Wait for server confirmation:
const channel = supabase.channel('room', {
config: {
broadcast: {
ack: true // Wait for acknowledgment
}
}
})
// Now send returns a promise
const status = await channel.send({
type: 'broadcast',
event: 'message',
payload: { text: 'Hello' }
})
if (status === 'ok') {
console.log('Message sent successfully')
}
Presence: Who's Online
Presence tracks connected clients and their state across the channel.
How Presence Works
┌─────────────────────────────────────────────────────────────┐
│ Presence State │
│ │
│ Channel: 'room-123' │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Presence State: │ │
│ │ │ │
│ │ { │ │
│ │ "user_1": [{ status: "online", name: "Alice" }], │ │
│ │ "user_2": [{ status: "away", name: "Bob" }], │ │
│ │ "user_3": [{ status: "online", name: "Carol" }] │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ When user_1 disconnects: │
│ - Server removes user_1 from state │
│ - All clients receive 'leave' event │
│ │
└─────────────────────────────────────────────────────────────┘
Basic Presence Example
const channel = supabase.channel('room-123')
// Listen for presence sync
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('Current users:', Object.keys(state))
})
// Listen for joins
channel.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log(`${key} joined:`, newPresences)
})
// Listen for leaves
channel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log(`${key} left:`, leftPresences)
})
// Subscribe and track your presence
await channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
username: currentUser.name,
online_at: new Date().toISOString()
})
}
})
Presence Events
| Event | When | Data |
|---|---|---|
sync | State updated | Full state available via presenceState() |
join | Client joins | { key, currentPresences, newPresences } |
leave | Client leaves | { key, currentPresences, leftPresences } |
Reading Presence State
// Get all presence state
const state = channel.presenceState()
// {
// "user_1": [{ user_id: "1", username: "Alice", ... }],
// "user_2": [{ user_id: "2", username: "Bob", ... }]
// }
// Count online users
const onlineCount = Object.keys(state).length
// Get list of users
const users = Object.entries(state).map(([key, presences]) => ({
key,
...presences[0]
}))
Updating Your Presence
// Initial track
await channel.track({
user_id: currentUser.id,
status: 'online'
})
// Update status
await channel.track({
user_id: currentUser.id,
status: 'away' // Changed
})
// Leave (stop tracking)
await channel.untrack()
Combining Features
A real application often uses all three realtime features together:
Collaborative Document Editor
const docId = 'doc-123'
const channel = supabase.channel(`document:${docId}`)
// 1. Postgres Changes: Sync document content
channel.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'documents',
filter: `id=eq.${docId}`
}, (payload) => {
updateDocumentContent(payload.new.content)
})
// 2. Broadcast: Cursor positions (ephemeral, high-frequency)
channel.on('broadcast', { event: 'cursor' }, ({ payload }) => {
updateCursorPosition(payload.userId, payload.position)
})
// Send cursor updates on mouse move
document.addEventListener('mousemove', throttle((e) => {
channel.send({
type: 'broadcast',
event: 'cursor',
payload: { userId: currentUser.id, position: { x: e.clientX, y: e.clientY } }
})
}, 50))
// 3. Presence: Who's viewing the document
channel.on('presence', { event: 'sync' }, () => {
const viewers = Object.values(channel.presenceState())
.flat()
.map(p => p.username)
updateViewersList(viewers)
})
// Subscribe and track
await channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
username: currentUser.name,
color: getRandomColor() // For cursor color
})
}
})
Chat Room
const roomId = 'room-123'
const channel = supabase.channel(`chat:${roomId}`)
// Postgres Changes: New messages
channel.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`
}, (payload) => {
addMessage(payload.new)
})
// Broadcast: Typing indicators
channel.on('broadcast', { event: 'typing' }, ({ payload }) => {
if (payload.userId !== currentUser.id) {
showTypingIndicator(payload.userId, payload.isTyping)
}
})
// Send typing indicator
let typingTimeout
input.addEventListener('input', () => {
channel.send({
type: 'broadcast',
event: 'typing',
payload: { userId: currentUser.id, isTyping: true }
})
clearTimeout(typingTimeout)
typingTimeout = setTimeout(() => {
channel.send({
type: 'broadcast',
event: 'typing',
payload: { userId: currentUser.id, isTyping: false }
})
}, 1000)
})
// Presence: Online users in room
channel.on('presence', { event: 'sync' }, () => {
updateOnlineUsers(channel.presenceState())
})
Broadcast vs Postgres Changes
When to use which:
| Scenario | Use | Why |
|---|---|---|
| Chat messages | Postgres Changes | Need persistence |
| Cursor position | Broadcast | High frequency, ephemeral |
| Document edits | Postgres Changes | Need persistence |
| Typing indicator | Broadcast | Ephemeral |
| Online status | Presence | Automatic tracking |
| Game score | Postgres Changes | Need persistence |
| Player movement | Broadcast | High frequency |
The Decision Framework
Need to persist? ──────→ Postgres Changes
│ (Write to DB, subscribe to changes)
│
No
│
▼
Need state tracking? ──→ Presence
│ (Who's online, their status)
│
No
│
▼
Ephemeral message ─────→ Broadcast
(Send to all subscribers)
Key Takeaways
- Broadcast is ephemeral: Messages aren't persisted
- Presence tracks state: Automatic join/leave detection
- Combine features: Use all three for rich experiences
- No database overhead: Broadcast/Presence don't write to DB
- Self-receive is optional: Configure per use case
- Acknowledgment available: Confirm message delivery
Next Steps
Understanding when to use each realtime feature, we'll explore scaling considerations for realtime applications.
Broadcast and Presence turn connections into conversations. While Postgres Changes syncs data, these features enable the human elements—seeing who's here, feeling their presence, sharing moments that don't need to be saved forever.

