TechLead
Lesson 20 of 22
5 min read
Supabase

Advanced Realtime Patterns

Build collaborative features with Supabase Realtime Presence, Broadcast, channel authorization, and live editing

Advanced Realtime Patterns

Supabase Realtime goes beyond database change listeners. With Presence, Broadcast, and channel authorization, you can build collaborative features like online status indicators, shared cursors, live editing, and ephemeral messaging — all without a separate WebSocket server.

🚀 Realtime Features

  • Presence: Track who is online, typing indicators, cursor positions
  • Broadcast: Send ephemeral messages to channel subscribers
  • Postgres Changes: Listen to INSERT, UPDATE, DELETE events
  • Authorization: Secure channels with RLS-like policies

Presence: Online Status & Typing Indicators

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(SUPABASE_URL, SUPABASE_KEY)

// Track who is online in a room
const channel = supabase.channel('room-1', {
  config: { presence: { key: userId } },
})

// Subscribe to presence changes
channel
  .on('presence', { event: 'sync' }, () => {
    const state = channel.presenceState()
    const onlineUsers = Object.keys(state)
    console.log('Online users:', onlineUsers)
  })
  .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') {
      // Track this user's presence
      await channel.track({
        user_id: userId,
        username: 'John',
        online_at: new Date().toISOString(),
      })
    }
  })

// Typing indicator — update presence state
async function setTyping(isTyping: boolean) {
  await channel.track({
    user_id: userId,
    username: 'John',
    is_typing: isTyping,
    online_at: new Date().toISOString(),
  })
}

Broadcast: Ephemeral Messages

// Broadcast sends messages without persisting them
// Perfect for cursor positions, reactions, notifications

const channel = supabase.channel('document-123')

// Listen for cursor movements from other users
channel
  .on('broadcast', { event: 'cursor-move' }, (payload) => {
    const { userId, x, y } = payload.payload
    updateCursorPosition(userId, x, y)
  })
  .on('broadcast', { event: 'reaction' }, (payload) => {
    showReaction(payload.payload.emoji)
  })
  .subscribe()

// Send cursor position (not persisted to database)
function onMouseMove(e: MouseEvent) {
  channel.send({
    type: 'broadcast',
    event: 'cursor-move',
    payload: {
      userId,
      x: e.clientX,
      y: e.clientY,
    },
  })
}

// Send a reaction
function sendReaction(emoji: string) {
  channel.send({
    type: 'broadcast',
    event: 'reaction',
    payload: { emoji, userId },
  })
}

Postgres Changes with Filters

// Listen to specific database changes
const channel = supabase
  .channel('project-updates')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
      filter: 'room_id=eq.room-123', // Only this room's messages
    },
    (payload) => {
      console.log('New message:', payload.new)
      addMessageToUI(payload.new)
    }
  )
  .on(
    'postgres_changes',
    {
      event: 'UPDATE',
      schema: 'public',
      table: 'messages',
      filter: 'room_id=eq.room-123',
    },
    (payload) => {
      console.log('Edited message:', payload.new)
      updateMessageInUI(payload.new)
    }
  )
  .subscribe()

Building Collaborative Features

// Collaborative document editing with shared cursors
function useCollaborativeEditor(documentId: string) {
  const channel = supabase.channel(`doc-${documentId}`, {
    config: { presence: { key: userId } },
  })

  // Track user presence with cursor position and selection
  channel
    .on('presence', { event: 'sync' }, () => {
      const state = channel.presenceState()
      // Render collaborator cursors
      Object.entries(state).forEach(([key, presences]) => {
        if (key !== userId) {
          const data = presences[0] as any
          renderCollaboratorCursor(key, data.cursor, data.color)
        }
      })
    })
    .subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        await channel.track({
          user_id: userId,
          username: currentUser.name,
          color: getRandomColor(),
          cursor: { line: 0, ch: 0 },
        })
      }
    })

  // Update cursor position as user types
  function onCursorChange(cursor: { line: number; ch: number }) {
    channel.track({
      user_id: userId,
      username: currentUser.name,
      color: assignedColor,
      cursor,
    })
  }

  return { channel, onCursorChange }
}

⚠️ Performance Tips

Throttle high-frequency events like cursor movements to 50-100ms intervals. Unsubscribe from channels when components unmount. Use Broadcast for ephemeral data (cursors, reactions) and Postgres Changes only for persistent data that needs database writes.

💡 Key Takeaways

  • • Presence tracks who is online and their state (typing, cursor position)
  • • Broadcast sends ephemeral messages without database writes
  • • Use filters on Postgres Changes to reduce unnecessary events
  • • Throttle high-frequency events for better performance
  • • Always clean up channel subscriptions on component unmount

📚 Learn More

Continue Learning