Realtime Architecture¶
!!! info "TL;DR" Supabase Realtime uses PostgreSQL CDC (Change Data Capture) to broadcast database changes via WebSocket. Clients subscribe to channels for live vote counts, new comments, status changes, and survey responses.
How Realtime Works¶
Supabase Realtime listens to PostgreSQL's Write-Ahead Log (WAL) and broadcasts changes to subscribed clients. The flow:
- Client subscribes → Opens WebSocket to Supabase Realtime
- Database change → INSERT/UPDATE/DELETE on subscribed table
- CDC trigger → PostgreSQL writes to WAL
- Realtime reads WAL → Detects change and broadcasts to channel
- Client receives event → Updates UI optimistically
Subscription Pattern¶
Basic Subscription¶
import { createBrowserClient } from '@/lib/supabase/client'
const supabase = createBrowserClient()
const channel = supabase
.channel('votes:submission=<id>')
.on(
'postgres_changes',
{
event: '*', // INSERT, UPDATE, DELETE, or '*' for all
schema: 'public',
table: 'votes',
filter: `submission_id=eq.<id>`,
},
(payload) => {
console.log('Vote change:', payload)
// payload.eventType: 'INSERT', 'UPDATE', or 'DELETE'
// payload.new: New row data (INSERT, UPDATE)
// payload.old: Old row data (UPDATE, DELETE)
}
)
.subscribe()
// Cleanup
return () => {
supabase.removeChannel(channel)
}
React Hook¶
// hooks/use-realtime-votes.ts
import { useEffect, useState } from 'react'
import { createBrowserClient } from '@/lib/supabase/client'
export function useRealtimeVotes(submissionId: string) {
const [voteCount, setVoteCount] = useState(0)
const supabase = createBrowserClient()
useEffect(() => {
// Initial fetch
supabase
.from('submissions')
.select('vote_count')
.eq('id', submissionId)
.single()
.then(({ data }) => {
if (data) setVoteCount(data.vote_count)
})
// Subscribe to changes
const channel = supabase
.channel(`votes:submission=${submissionId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'votes',
filter: `submission_id=eq.${submissionId}`,
},
(payload) => {
// Update vote count
if (payload.eventType === 'INSERT') {
setVoteCount((prev) => prev + 1)
} else if (payload.eventType === 'DELETE') {
setVoteCount((prev) => prev - 1)
}
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [submissionId])
return voteCount
}
Usage in Component¶
'use client'
import { useRealtimeVotes } from '@/hooks/use-realtime-votes'
export function VoteButton({ submissionId }) {
const voteCount = useRealtimeVotes(submissionId)
return <button>▲ {voteCount}</button>
}
Common Realtime Patterns¶
Live Vote Counts¶
Subscribe to vote changes for a submission:
const channel = supabase
.channel(`votes:submission=${submissionId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'votes',
filter: `submission_id=eq.${submissionId}`,
},
handleVoteChange
)
.subscribe()
New Comments¶
Subscribe to new comments on a submission:
const channel = supabase
.channel(`comments:submission=${submissionId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'comments',
filter: `submission_id=eq.${submissionId}`,
},
(payload) => {
const newComment = payload.new
// Add to comment list
setComments((prev) => [...prev, newComment])
}
)
.subscribe()
Status Changes¶
Subscribe to submission status updates:
const channel = supabase
.channel(`submission:${submissionId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'submissions',
filter: `id=eq.${submissionId}`,
},
(payload) => {
const { old: oldRow, new: newRow } = payload
if (oldRow.status !== newRow.status) {
// Status changed, show notification
toast.success(`Status changed to ${newRow.status}`)
setStatus(newRow.status)
}
}
)
.subscribe()
Survey Responses¶
Subscribe to new survey responses (admin only):
const channel = supabase
.channel(`survey-responses:${surveyId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'survey_responses',
filter: `survey_id=eq.${surveyId}`,
},
(payload) => {
const newResponse = payload.new
// Increment response count
setResponseCount((prev) => prev + 1)
// Show toast
toast.success('New response received')
}
)
.subscribe()
Broadcast Pattern¶
For ephemeral data (typing indicators, presence), use broadcast instead of database changes:
// Sender
const channel = supabase.channel('room:123')
channel.on('broadcast', { event: 'typing' }, (payload) => {
console.log('User typing:', payload.user)
})
channel.subscribe((status) => {
if (status === 'SUBSCRIBED') {
channel.send({
type: 'broadcast',
event: 'typing',
payload: { user: 'Alice' },
})
}
})
Broadcast messages are not persisted. They're only delivered to currently connected clients.
Presence Pattern¶
Track who's currently viewing a page:
const channel = supabase.channel('room:123', {
config: {
presence: {
key: userId,
},
},
})
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('Online users:', Object.keys(state))
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log('User joined:', key)
})
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log('User left:', key)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user: userName,
online_at: new Date().toISOString(),
})
}
})
Performance Considerations¶
Channel Limits¶
Each client can subscribe to 100 channels simultaneously. Exceeding this limit will cause errors.
For large lists (e.g., 500 submissions on a page), subscribe to a single aggregate channel instead of one per submission:
// ❌ Bad: Subscribe to each submission individually
submissions.forEach((s) => {
subscribeToVotes(s.id) // 500 channels!
})
// ✅ Good: Subscribe to a single channel for all submissions
const channel = supabase
.channel('votes:all')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'votes',
},
(payload) => {
// Update the relevant submission in local state
updateSubmission(payload.new.submission_id, payload)
}
)
.subscribe()
Filter Specificity¶
Use specific filters to reduce message volume:
// ❌ Bad: Receives all vote events, filters client-side
.on('postgres_changes', { event: '*', schema: 'public', table: 'votes' }, ...)
// ✅ Good: Server filters to only relevant events
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'votes',
filter: `submission_id=eq.${submissionId}`,
}, ...)
Cleanup¶
Always unsubscribe when components unmount:
useEffect(() => {
const channel = supabase.channel('...')
// ... subscribe
return () => {
supabase.removeChannel(channel)
}
}, [])
Forgetting to unsubscribe causes memory leaks and stale subscriptions.
RLS and Realtime¶
Realtime events respect RLS policies. If a user doesn't have permission to read a row, they won't receive realtime events for it.
Example: Comments table has RLS policy:
All users receive realtime comment events.
If the policy were:
CREATE POLICY "Team only" ON comments
FOR SELECT USING (
EXISTS (SELECT 1 FROM team_members WHERE user_id = auth.uid())
);
Only team members would receive comment events.
Debugging¶
Check Subscription Status¶
channel.subscribe((status) => {
console.log('Channel status:', status)
// SUBSCRIBED, TIMED_OUT, CLOSED, CHANNEL_ERROR
})
Check WAL Configuration¶
Realtime requires PostgreSQL logical replication. Check wal_level:
If not, enable it:
Monitor Realtime Dashboard¶
Supabase dashboard has a Realtime section showing:
- Active connections
- Messages per second
- Channel subscriptions
Use this to debug connection issues and monitor load.
What's Next¶
- Database Schema — Tables that trigger realtime events
- System Overview — How realtime fits into the architecture
- API Reference — REST API for initial data fetch