Skip to content

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:

  1. Client subscribes → Opens WebSocket to Supabase Realtime
  2. Database change → INSERT/UPDATE/DELETE on subscribed table
  3. CDC trigger → PostgreSQL writes to WAL
  4. Realtime reads WAL → Detects change and broadcasts to channel
  5. 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:

CREATE POLICY "Public read" ON comments
  FOR SELECT USING (true);

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:

SHOW wal_level; -- Should be 'logical'

If not, enable it:

ALTER SYSTEM SET wal_level = 'logical';
-- Restart PostgreSQL

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