Switch Language
Toggle Theme

Supabase Realtime in Practice: Comparing Three Modes and Building Collaborative Applications

At 3 AM, I stared at the “typing…” indicator on my screen, refreshing the chat page for the seventeenth time. My friend on the other end was clearly online, but the messages just wouldn’t appear. That moment hit me: building a truly “real-time” application is way harder than I imagined.

I’ve stepped into plenty of WebSocket pitfalls—handling reconnections when connections drop, managing state synchronization, designing message broadcasting. It wasn’t until last year when I started using Supabase Realtime that I realized these headaches could be someone else’s problem. Supabase offers three real-time modes: Postgres Changes for listening to database changes, Presence for tracking user status, and Broadcast for broadcasting temporary messages. Each mode has its own use case—use them correctly and you’ll save tons of time, use them wrong and you’re just digging yourself into a hole.

In this article, I’ll break down all three modes clearly—when to use which one, how to write the code, and how to configure RLS security policies. Finally, we’ll combine them to build a complete collaborative chat application.

Comparing Supabase Realtime’s Three Core Features

Let’s start with the conclusion: each mode has its own job, don’t mix them up.

Postgres Changes listens for database changes. Perfect for chat messages, notifications, order status—data that needs to be persisted. Data goes into the database, clients just subscribe to changes.

Presence tracks user online status. Great for showing “who’s online,” typing indicators, cursor positions in collaborative editing. Data doesn’t go into the database—it lives in memory and disappears when users disconnect.

Broadcast broadcasts temporary messages. Ideal for cursor movements on canvases, real-time positions in games, temporary operation synchronization. The difference from Presence: Broadcast is for high-frequency sending, Presence is for state synchronization.

Here’s a table to make it clear:

ModeWhere Data is StoredTypical Use CasesPersistent
Postgres ChangesPostgreSQLChat messages, notifications, order statusYes
PresenceMemory (Realtime service)Online users, typing indicatorsNo
BroadcastNot stored (instant forwarding)Cursor movement, real-time positionsNo

You might wonder: why not just use Postgres Changes for everything? Honestly, I thought the same thing at first. Later, when building a collaborative whiteboard app, I wrote every cursor movement to the database, and the database CPU shot up to 90%. That’s when I realized: some data just doesn’t need to be persisted.

To choose the right mode, remember this simple principle: Use Postgres Changes for data that needs history, Presence for current state, and Broadcast for high-frequency temporary data.

Postgres Changes: Listening to Database Changes

This section covers the most common scenario: listening to database changes. Things like new messages in a chat room, order status updates, likes—these all need persistent data.

Supabase Realtime implements change listening through PostgreSQL’s logical replication mechanism. Simply put, every time the database has an INSERT, UPDATE, or DELETE operation, the Realtime service captures it and pushes it to subscribed clients.

Enabling Realtime Listening

First, you need to enable publication on the database side. Run this in Supabase SQL Editor:

-- Enable Realtime publication
ALTER publication supabase_realtime ADD TABLE messages;

-- For tables that need UPDATE and DELETE monitoring, must set REPLICA IDENTITY FULL
ALTER TABLE messages REPLICA IDENTITY FULL;

Why set REPLICA IDENTITY FULL? By default, PostgreSQL only records the primary key of changed rows. If you need complete data before and after changes (like for audit logs), you must enable this option. Note: this increases database write volume, only enable it for tables that truly need it.

Client Subscription Code

Next is the client code. Let’s use chat messages as an example:

import { createClient } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)

interface Message {
  id: string
  content: string
  user_id: string
  created_at: string
}

export function useRealtimeMessages(roomId: string) {
  const [messages, setMessages] = useState<Message[]>([])

  useEffect(() => {
    // First fetch historical messages
    const fetchMessages = async () => {
      const { data } = await supabase
        .from('messages')
        .select('*')
        .eq('room_id', roomId)
        .order('created_at', { ascending: true })

      if (data) setMessages(data)
    }

    fetchMessages()

    // Subscribe to new messages
    const channel = supabase
      .channel(`messages:${roomId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',      // Only listen for inserts
          schema: 'public',
          table: 'messages',
          filter: `room_id=eq.${roomId}`  // Filter for specific room
        },
        (payload) => {
          // payload.new contains the newly inserted data
          setMessages(prev => [...prev, payload.new as Message])
        }
      )
      .subscribe()

    // Cleanup subscription
    return () => {
      supabase.removeChannel(channel)
    }
  }, [roomId])

  return messages
}

A few details worth noting:

  1. Fetch history first, then subscribe to increments: When entering a chat room, you need to show historical messages first, then receive new ones. This is a common pitfall—don’t just subscribe without fetching history.
  2. filter parameter: Use database fields to filter and avoid receiving irrelevant messages. The syntax is field_name=eq.value.
  3. Cleanup subscription: Remember to removeChannel when the component unmounts, otherwise you’ll have memory leaks.

RLS Security Configuration (Important!)

Many people overlook this, but it’s critical for production applications. By default, Realtime follows the table’s RLS (Row Level Security) policies. If you don’t configure RLS, clients might receive nothing, or receive data they shouldn’t see.

Let’s look at an example with a chat room table:

-- Messages table
CREATE TABLE messages (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  room_id uuid REFERENCES rooms(id),
  user_id uuid REFERENCES auth.users(id),
  content text NOT NULL,
  created_at timestamptz DEFAULT now()
);

-- Enable RLS
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

-- Allow viewing messages in own rooms
CREATE POLICY "Users can view messages in their rooms"
ON messages FOR SELECT
USING (
  room_id IN (
    SELECT room_id FROM room_members
    WHERE user_id = auth.uid()
  )
);

-- Allow room members to send messages
CREATE POLICY "Room members can insert messages"
ON messages FOR INSERT
WITH CHECK (
  room_id IN (
    SELECT room_id FROM room_members
    WHERE user_id = auth.uid()
  )
);

Realtime subscriptions automatically apply these policies. Users only receive message changes they have permission to see. This is especially important—don’t try to filter on the frontend, that’s not secure.

If you find subscriptions aren’t receiving data, check two things first:

  1. Whether the table is added to supabase_realtime publication
  2. Whether RLS policies are configured correctly

Presence: Tracking User Online Status

Presence is perfect for those “who’s online right now” scenarios—showing online user count in chat rooms, who’s editing what in collaborative documents, who’s typing. This data doesn’t need to go into the database; storing it in memory is enough.

Basic Principles

Here’s how Presence works: after each client joins a channel, they call the track() method to register their status. The Realtime service maintains a snapshot of all client statuses, and when someone joins, leaves, or updates their status, all subscribers receive notifications.

Implementing Online User List

Let’s look at the code. Here’s a component that displays online users in a chat room:

import { createClient } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)

interface UserPresence {
  user_id: string
  username: string
  online_at: string
}

export function useOnlineUsers(roomId: string, currentUser: { id: string; username: string }) {
  const [users, setUsers] = useState<UserPresence[]>([])

  useEffect(() => {
    const channel = supabase.channel(`room:${roomId}`, {
      config: {
        presence: {
          key: currentUser.id  // Use user ID as key
        }
      }
    })

    channel
      .on('presence', { event: 'sync' }, () => {
        // Get all online users on sync
        const state = channel.presenceState()
        // presenceState() returns { [key]: [UserPresence, ...] }
        const onlineUsers = Object.values(state).flat() as UserPresence[]
        setUsers(onlineUsers)
      })
      .on('presence', { event: 'join' }, ({ newPresences }) => {
        // New user joined
        console.log('User joined:', newPresences)
      })
      .on('presence', { event: 'leave' }, ({ leftPresences }) => {
        // User left
        console.log('User left:', leftPresences)
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          // After successful subscription, register own status
          await channel.track({
            user_id: currentUser.id,
            username: currentUser.username,
            online_at: new Date().toISOString()
          })
        }
      })

    return () => {
      supabase.removeChannel(channel)
    }
  }, [roomId, currentUser])

  return users
}

Using it is simple:

function ChatRoom({ roomId, currentUser }) {
  const onlineUsers = useOnlineUsers(roomId, currentUser)

  return (
    <div className="flex items-center gap-2 mb-4">
      <span className="text-sm text-gray-500">
        {onlineUsers.length} online
      </span>
      <div className="flex -space-x-2">
        {onlineUsers.map(user => (
          <div
            key={user.user_id}
            className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm"
            title={user.username}
          >
            {user.username[0]}
          </div>
        ))}
      </div>
    </div>
  )
}

Typing Indicator

Presence can also create “typing” indicators. The idea is: when users start typing, update their status; after they stop typing for a while, clear the status.

export function useTypingIndicator(roomId: string, currentUser: { id: string }) {
  const [typingUsers, setTypingUsers] = useState<string[]>([])
  const channelRef = useRef<RealtimeChannel | null>(null)
  const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)

  useEffect(() => {
    const channel = supabase.channel(`room:${roomId}`)
    channelRef.current = channel

    channel
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState()
        const typing = Object.values(state)
          .flat()
          .filter((u: any) => u.is_typing && u.user_id !== currentUser.id)
          .map((u: any) => u.username)
        setTypingUsers(typing)
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          await channel.track({
            user_id: currentUser.id,
            is_typing: false
          })
        }
      })

    return () => {
      supabase.removeChannel(channel)
    }
  }, [roomId, currentUser])

  // Call when user starts typing
  const setTyping = (isTyping: boolean) => {
    if (typingTimeoutRef.current) {
      clearTimeout(typingTimeoutRef.current)
    }

    channelRef.current?.track({
      user_id: currentUser.id,
      is_typing
    })

    // Auto-clear typing status after 3 seconds
    if (isTyping) {
      typingTimeoutRef.current = setTimeout(() => {
        channelRef.current?.track({
          user_id: currentUser.id,
          is_typing: false
        })
      }, 3000)
    }
  }

  return { typingUsers, setTyping }
}

There’s a small pitfall here: don’t call track() on every keystroke—it’s too frequent and will cause issues. Add debouncing, or like above, auto-clear the status a few seconds after typing stops.

Broadcast: Cursor Tracking and Instant Messaging

Broadcast is the “lightest” of the three modes—messages aren’t stored, aren’t persisted, send and forget. Perfect for high-frequency, temporary data transmission.

Cursor Tracking Example

Applications like collaborative whiteboards and multi-user editors need to show everyone’s cursor position in real-time. This scenario is perfect for Broadcast—cursor positions don’t need to go into the database, and there’s no need to know “where the cursor was historically.”

import { createClient } from '@supabase/supabase-js'
import { useEffect, useState, useRef } from 'react'

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)

interface CursorPosition {
  user_id: string
  username: string
  x: number
  y: number
  color: string
}

export function useCursors(canvasId: string, currentUser: { id: string; username: string }) {
  const [cursors, setCursors] = useState<Record<string, CursorPosition>>(})
  const channelRef = useRef<RealtimeChannel | null>(null)

  useEffect(() => {
    const channel = supabase.channel(`canvas:${canvasId}`)
    channelRef.current = channel

    channel
      .on('broadcast', { event: 'cursor' }, ({ payload }) => {
        // Received other user's cursor position
        if (payload.user_id !== currentUser.id) {
          setCursors(prev => ({
            ...prev,
            [payload.user_id]: payload
          }))
        }
      })
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [canvasId, currentUser])

  // Send own cursor position
  const sendCursor = (x: number, y: number) => {
    channelRef.current?.send({
      type: 'broadcast',
      event: 'cursor',
      payload: {
        user_id: currentUser.id,
        username: currentUser.username,
        x,
        y,
        color: getUserColor(currentUser.id)
      }
    })
  }

  return { cursors, sendCursor }
}

// Generate color based on user ID
function getUserColor(userId: string): string {
  const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4']
  const index = userId.charCodeAt(0) % colors.length
  return colors[index]
}

Using it in a component:

function CollaborativeCanvas({ canvasId, currentUser }) {
  const { cursors, sendCursor } = useCursors(canvasId, currentUser)

  const handleMouseMove = (e: React.MouseEvent) => {
    sendCursor(e.clientX, e.clientY)
  }

  return (
    <div className="relative w-full h-full" onMouseMove={handleMouseMove}>
      {/* Display other users' cursors */}
      {Object.entries(cursors).map(([userId, cursor]) => (
        <div
          key={userId}
          className="absolute pointer-events-none"
          style={{ left: cursor.x, top: cursor.y }}
        >
          <div className="w-4 h-4 rounded-full" style={{ backgroundColor: cursor.color }} />
          <span className="text-xs ml-1">{cursor.username}</span>
        </div>
      ))}
    </div>
  )
}

Broadcast vs Presence

You might notice that Broadcast and Presence overlap. The difference is:

  • Broadcast is for high-frequency sending (potentially dozens of times per second), like cursor movement, position syncing in games
  • Presence is for state synchronization (occasional updates), like “who’s online,” “currently typing”

They work best together. Broadcast is for “moving” data, Presence is for “state” data.

Complete Practice: Building a Collaborative Chat Application

Now let’s combine all three modes to build a real collaborative chat application. Features include:

  • Real-time messages (Postgres Changes)
  • Online user list (Presence)
  • Typing indicator (Presence)

Database Setup

First, create the tables:

-- Rooms table
CREATE TABLE rooms (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,
  created_at timestamptz DEFAULT now()
);

-- Room members table
CREATE TABLE room_members (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
  user_id uuid REFERENCES auth.users(id),
  joined_at timestamptz DEFAULT now(),
  UNIQUE(room_id, user_id)
);

-- Messages table
CREATE TABLE messages (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
  user_id uuid REFERENCES auth.users(id),
  content text NOT NULL,
  created_at timestamptz DEFAULT now()
);

-- Enable Realtime
ALTER publication supabase_realtime ADD TABLE messages;

-- Enable RLS
ALTER TABLE messages ENABLE ROW LEVEL Security;

-- RLS policy: only view messages in own rooms
CREATE POLICY "Users can view messages in their rooms"
ON messages FOR SELECT
USING (
  room_id IN (
    SELECT room_id FROM room_members WHERE user_id = auth.uid()
  )
);

-- RLS policy: only room members can send messages
CREATE POLICY "Room members can send messages"
ON messages FOR INSERT
WITH CHECK (
  room_id IN (
    SELECT room_id FROM room_members WHERE user_id = auth.uid()
  )
);

Complete React Component

Here’s a simplified collaborative chat component using all three modes:

import { createClient } from '@supabase/supabase-js'
import { useEffect, useState, useRef, useCallback } from 'react'

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)

interface Message {
  id: string
  content: string
  user_id: string
  username: string
  created_at: string
}

interface UserPresence {
  user_id: string
  username: string
  is_typing?: boolean
}

export function CollaborativeChat({ roomId, currentUser }) {
  const [messages, setMessages] = useState<Message[]>([])
  const [onlineUsers, setOnlineUsers] = useState<UserPresence[]>([])
  const [typingUsers, setTypingUsers] = useState<string[]>([])
  const [inputValue, setInputValue] = useState('')

  const channelRef = useRef<RealtimeChannel | null>(null)
  const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)

  useEffect(() => {
    // Create one channel, shared by all three features
    const channel = supabase.channel(`room:${roomId}`, {
      config: { presence: { key: currentUser.id } }
    })
    channelRef.current = channel

    // 1. Postgres Changes: listen for new messages
    channel.on(
      'postgres_changes',
      {
        event: 'INSERT',
        schema: 'public',
        table: 'messages',
        filter: `room_id=eq.${roomId}`
      },
      (payload) => setMessages(prev => [...prev, payload.new as Message])
    )

    // 2. Presence: listen for online users and typing status
    channel.on('presence', { event: 'sync' }, () => {
      const state = channel.presenceState()
      const users = Object.values(state).flat() as UserPresence[]
      setOnlineUsers(users)

      const typing = users
        .filter(u => u.is_typing && u.user_id !== currentUser.id)
        .map(u => u.username)
      setTypingUsers(typing)
    })

    // Subscribe and register Presence
    channel.subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        await channel.track({
          user_id: currentUser.id,
          username: currentUser.username,
          is_typing: false
        })

        // Load historical messages
        const { data } = await supabase
          .from('messages')
          .select('*')
          .eq('room_id', roomId)
          .order('created_at', { ascending: true })

        if (data) setMessages(data)
      }
    })

    return () => supabase.removeChannel(channel)
  }, [roomId, currentUser])

  const sendMessage = async () => {
    if (!inputValue.trim()) return
    await supabase.from('messages').insert({
      room_id: roomId,
      user_id: currentUser.id,
      content: inputValue.trim()
    })
    setInputValue('')
    setTyping(false)
  }

  const setTyping = useCallback((isTyping: boolean) => {
    if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
    channelRef.current?.track({
      user_id: currentUser.id,
      username: currentUser.username,
      is_typing
    })
    if (isTyping) {
      typingTimeoutRef.current = setTimeout(() => setTyping(false), 3000)
    }
  }, [currentUser])

  return (
    <div className="flex h-full">
      {/* Left: Online users */}
      <div className="w-64 border-r p-4">
        <h3 className="font-bold mb-2">Online ({onlineUsers.length})</h3>
        {onlineUsers.map(user => (
          <div key={user.user_id} className="py-1">
            {user.username}
            {user.is_typing && <span className="text-sm text-gray-500"> (typing)</span>}
          </div>
        ))}
      </div>

      {/* Right: Chat */}
      <div className="flex-1 flex flex-col">
        <div className="flex-1 overflow-y-auto p-4">
          {messages.map(msg => (
            <div key={msg.id} className="mb-2">
              <span className="font-bold">{msg.username}: </span>
              {msg.content}
            </div>
          ))}
          {typingUsers.length > 0 && (
            <div className="text-gray-500 text-sm">
              {typingUsers.join(', ')} typing...
            </div>
          )}
        </div>

        <div className="p-4 border-t">
          <input
            value={inputValue}
            onChange={e => { setInputValue(e.target.value); setTyping(true) }}
            onKeyDown={e => e.key === 'Enter' && sendMessage()}
            placeholder="Type a message..."
            className="w-full p-2 border rounded"
          />
        </div>
      </div>
    </div>
  )
}

Key Design Points

  1. Share one Channel: All three features use the same channel, reducing connection count.
  2. Presence data structure: Put is_typing in the same presence object.
  3. Cleanup: Always removeChannel when the component unmounts.

Performance and Security Best Practices

By now, the code is working. But before going live, there are some details to handle.

Connection Management

Each channel occupies one WebSocket connection. Although Supabase supports multiple channels sharing one connection, abuse will still cause problems.

Best practices:

  • Maximum 2-3 channels per page
  • Related features share one channel (like the chat example above)
  • Immediately removeChannel when leaving the page
// Cleanup example
useEffect(() => {
  const channel = supabase.channel('my-channel')
  channel.subscribe()

  return () => {
    // Don't just unsubscribe, use removeChannel
    supabase.removeChannel(channel)
  }
}, [])

RLS is Mandatory

Don’t think “frontend filtering is enough.” Realtime subscriptions automatically apply RLS policies—users only receive data changes they have permission to see.

If subscriptions aren’t receiving data, check in this order:

  1. Is the table added to supabase_realtime publication
  2. Are RLS policies correct (use Supabase Dashboard’s RLS testing tool)
  3. Is the user logged in (what does auth.uid() return)

Pricing Considerations

Supabase Realtime billing is based on concurrent connections: $10 per 1,000 peak connections. For most small to medium applications, the free tier is enough. But if your application has lots of users online simultaneously, watch your channel count.

There’s an official demo project Multiplayer.dev—check it out to see all three modes in action.

Conclusion

After all this, choosing which mode is actually simple:

Need History?High Frequency?Recommended Mode
YesNoPostgres Changes
NoNoPresence
NoYesBroadcast

If you’re building collaborative applications (chat rooms, whiteboards, document editing), you’ll likely use all three modes. Postgres Changes for messages, Presence for online status, Broadcast for cursors.

I recommend checking out Multiplayer.dev first to experience how it actually works. Then build a small chat demo—that’s the fastest way to get started.

Next article, I plan to write about Supabase Storage—file uploads and image processing. If you have any questions about Realtime, feel free to leave a comment.

FAQ

What's the difference between Supabase Realtime's three modes?
Each mode has its own job:

• Postgres Changes listens to database changes, data is persisted, perfect for chat messages, order status
• Presence tracks user online status, data is in memory, perfect for online lists, typing indicators
• Broadcast broadcasts temporary messages, not stored, perfect for cursor tracking, real-time positions
Why isn't my subscription receiving data?
Check three things:

1. Is the table added to supabase_realtime publication (run ALTER publication supabase_realtime ADD TABLE table_name)
2. Are RLS policies configured correctly (Realtime subscriptions automatically apply RLS)
3. Does the user have permission to view the data (check auth.uid() return value)
Should I choose Postgres Changes or Broadcast?
Need to store history and support backtracking? Choose Postgres Changes. High-frequency temporary data that doesn't need persistence? Choose Broadcast. For example, chat messages use the former, cursor positions use the latter.
Is Presence data persisted?
No. Presence data is stored in Realtime service memory and disappears when users disconnect. Perfect for temporary data like online status.
How does Realtime subscription affect RLS?
Realtime subscriptions automatically apply table RLS policies. Users only receive data changes they have permission to see. This is a security key for production applications—don't filter on the frontend.
How many channels can I use on one page?
Recommend maximum 2-3. Related features share one channel (like chat using Postgres Changes + Presence together), reducing WebSocket connection count. Always call removeChannel to cleanup when leaving the page.

9 min read · Published on: Apr 15, 2026 · Modified on: Apr 15, 2026

Comments

Sign in with GitHub to leave a comment