Developer Guide

Supabase Realtime: Guide to Building Live Applications

Master Supabase Realtime: Postgres Changes, Presence, Broadcast, and building real-time features like chat, notifications, collaborative editing.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated

Supabase Realtime enables you to build live, collaborative applications with minimal code. This guide covers everything from basic subscriptions to advanced patterns.

What is Supabase Realtime?#

Supabase Realtime is built on top of PostgreSQL's replication functionality and provides three main features, as laid out in Supabase's own Realtime documentation:

  1. Postgres Changes - Listen to database changes (INSERT, UPDATE, DELETE)
  2. Presence - Track and sync user state across clients
  3. Broadcast - Send ephemeral messages between clients

Architecture Overview#

plaintext
Client → Supabase Realtime Server → PostgreSQL
   ↓                                      ↓
WebSocket                          Replication Slot
   ↓                                      ↓
Live Updates ← ← ← ← ← ← ← ← ← ← ← ← ← ← ←

Setup and Configuration#

1. Enable Realtime on Tables#

Adding the table to the supabase_realtime publication is the mandatory step — without it, no Postgres Changes events are emitted, regardless of RLS or client code:

sql
-- Required: register the table with the realtime publication
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
 
-- Or in Supabase Dashboard:
-- Publications settings → under supabase_realtime, toggle the table on

REPLICA IDENTITY FULL is a separate, optional step — only needed if you want the old record on UPDATE/DELETE payloads or need to filter delete events:

sql
-- Optional: include full old row data on UPDATE/DELETE payloads
ALTER TABLE messages REPLICA IDENTITY FULL;

2. Row Level Security (RLS)#

sql
-- Enable RLS
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
 
-- Policy for reading messages
CREATE POLICY "Users can read messages in their channels"
ON messages FOR SELECT
USING (
  auth.uid() IN (
    SELECT user_id FROM channel_members 
    WHERE channel_id = messages.channel_id
  )
);
 
-- Policy for inserting messages
CREATE POLICY "Users can insert messages in their channels"
ON messages FOR INSERT
WITH CHECK (
  auth.uid() IN (
    SELECT user_id FROM channel_members 
    WHERE channel_id = messages.channel_id
  )
);

Postgres Changes: Database Subscriptions#

Basic Subscription#

typescript
// @supabase/auth-helpers-nextjs is archived — use @supabase/ssr instead. createBrowserClient is for Client Components.
import { createBrowserClient } from '@supabase/ssr';
import { useEffect, useState } from 'react';
 
export default function Messages() {
  const [messages, setMessages] = useState([]);
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  );
 
  useEffect(() => {
    // Fetch initial data
    const fetchMessages = async () => {
      const { data } = await supabase
        .from('messages')
        .select('*')
        .order('created_at', { ascending: true });
      
      setMessages(data || []);
    };
 
    fetchMessages();
 
    // Subscribe to new messages
    const channel = supabase
      .channel('messages')
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages'
        },
        (payload) => {
          setMessages(prev => [...prev, payload.new]);
        }
      )
      .subscribe();
 
    return () => {
      supabase.removeChannel(channel);
    };
  }, [supabase]);
 
  return (
    <div>
      {messages.map(msg => (
        <div key={msg.id}>{msg.content}</div>
      ))}
    </div>
  );
}

Listen to All Events#

typescript
const channel = supabase
  .channel('all-changes')
  .on(
    'postgres_changes',
    {
      event: '*', // INSERT, UPDATE, DELETE
      schema: 'public',
      table: 'messages'
    },
    (payload) => {
      console.log('Change received!', payload);
      
      switch (payload.eventType) {
        case 'INSERT':
          setMessages(prev => [...prev, payload.new]);
          break;
        case 'UPDATE':
          setMessages(prev => 
            prev.map(msg => 
              msg.id === payload.new.id ? payload.new : msg
            )
          );
          break;
        case 'DELETE':
          setMessages(prev => 
            prev.filter(msg => msg.id !== payload.old.id)
          );
          break;
      }
    }
  )
  .subscribe();

Filter by Column Value#

typescript
// Only listen to messages in a specific channel
const channel = supabase
  .channel('channel-messages')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
      filter: `channel_id=eq.${channelId}` // Filter by channel
    },
    (payload) => {
      setMessages(prev => [...prev, payload.new]);
    }
  )
  .subscribe();

Presence: Track Online Users#

Per Supabase's Presence documentation, track() broadcasts arbitrary JSON state to every other client on the same channel and presenceState() returns the merged state of every client currently connected to the channel. Each client's payload is stored under a presence key: a server-generated UUID by default, or the custom key you pass in the channel config, as below.

Basic Presence Implementation#

typescript
'use client';
 
import { useEffect, useState } from 'react';
import { createBrowserClient } from '@supabase/ssr';
 
export default function OnlineUsers({ roomId, currentUser }) {
  const [onlineUsers, setOnlineUsers] = useState([]);
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  );
 
  useEffect(() => {
    const channel = supabase.channel(`room:${roomId}`, {
      config: {
        presence: {
          key: currentUser.id
        }
      }
    });
 
    // Track presence
    channel
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState();
        const users = Object.values(state).flat();
        setOnlineUsers(users);
      })
      .on('presence', { event: 'join' }, ({ newPresences }) => {
        console.log('User joined:', newPresences);
      })
      .on('presence', { event: 'leave' }, ({ leftPresences }) => {
        console.log('User left:', leftPresences);
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          // Track current user
          await channel.track({
            user_id: currentUser.id,
            username: currentUser.username,
            avatar: currentUser.avatar,
            online_at: new Date().toISOString()
          });
        }
      });
 
    return () => {
      channel.untrack();
      supabase.removeChannel(channel);
    };
  }, [roomId, currentUser, supabase]);
 
  return (
    <div>
      <h3>Online Users ({onlineUsers.length})</h3>
      <ul>
        {onlineUsers.map(user => (
          <li key={user.user_id}>
            <img src={user.avatar} alt={user.username} />
            {user.username}
          </li>
        ))}
      </ul>
    </div>
  );
}

Typing Indicators#

typescript
import { useEffect, useState, useRef } from 'react';
 
export default function ChatInput({ channelId, currentUser }) {
  const [typingUsers, setTypingUsers] = useState([]);
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  );
  // Presence.track() only works after the channel reaches SUBSCRIBED — it
  // cannot be called on a freshly created, unjoined channel instance. Keep a
  // ref to the one channel that was actually subscribed in the effect below.
  const channelRef = useRef(null);
  const typingTimeoutRef = useRef(null);
 
  useEffect(() => {
    const channel = supabase.channel(`typing:${channelId}`);
 
    channel
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState();
        const typing = Object.values(state)
          .flat()
          .filter(user => user.user_id !== currentUser.id);
        setTypingUsers(typing);
      })
      .subscribe((status) => {
        if (status === 'SUBSCRIBED') {
          channelRef.current = channel;
        }
      });
 
    return () => {
      channelRef.current = null;
      supabase.removeChannel(channel);
    };
  }, [channelId, currentUser, supabase]);
 
  const handleTyping = async () => {
    const channel = channelRef.current;
    if (!channel) return; // not SUBSCRIBED yet — skip this keystroke
 
    // Track typing
    await channel.track({
      user_id: currentUser.id,
      username: currentUser.username,
      typing: true
    });
 
    // Clear previous timeout
    if (typingTimeoutRef.current) {
      clearTimeout(typingTimeoutRef.current);
    }
 
    // Stop tracking after 3 seconds
    typingTimeoutRef.current = setTimeout(async () => {
      await channel.untrack();
    }, 3000);
  };
 
  return (
    <div>
      {typingUsers.length > 0 && (
        <div className="text-sm text-gray-500">
          {typingUsers.map(u => u.username).join(', ')} 
          {typingUsers.length === 1 ? ' is' : ' are'} typing...
        </div>
      )}
      <input
        type="text"
        onChange={handleTyping}
        placeholder="Type a message..."
      />
    </div>
  );
}

Broadcast: Send Ephemeral Messages#

Per Supabase's Broadcast documentation, Broadcast sends low-latency messages between connected clients through the Realtime server without a write to your tables, which suits per-frame data like cursor positions. The exception is Broadcast from the Database: realtime.send() inserts into the realtime.messages table, where messages are kept for three days.

Cursor Tracking#

typescript
export default function CollaborativeCanvas({ documentId, currentUser }) {
  const [cursors, setCursors] = useState({});
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  );
 
  useEffect(() => {
    const channel = supabase.channel(`canvas:${documentId}`);
 
    channel
      .on('broadcast', { event: 'cursor' }, ({ payload }) => {
        setCursors(prev => ({
          ...prev,
          [payload.user_id]: payload
        }));
      })
      .subscribe();
 
    return () => {
      supabase.removeChannel(channel);
    };
  }, [documentId, supabase]);
 
  const handleMouseMove = async (e) => {
    const channel = supabase.channel(`canvas:${documentId}`);
    
    await channel.send({
      type: 'broadcast',
      event: 'cursor',
      payload: {
        user_id: currentUser.id,
        x: e.clientX,
        y: e.clientY,
        username: currentUser.username
      }
    });
  };
 
  return (
    <div onMouseMove={handleMouseMove}>
      {/* Render other users' cursors */}
      {Object.values(cursors).map(cursor => (
        <div
          key={cursor.user_id}
          style={{
            position: 'absolute',
            left: cursor.x,
            top: cursor.y,
            pointerEvents: 'none'
          }}
        >
          <div className="cursor-pointer">
            {cursor.username}
          </div>
        </div>
      ))}
      
      {/* Your canvas content */}
    </div>
  );
}

Live Reactions#

typescript
export default function LiveReactions({ postId, currentUser }) {
  const [reactions, setReactions] = useState([]);
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  );
 
  useEffect(() => {
    const channel = supabase.channel(`reactions:${postId}`);
 
    channel
      .on('broadcast', { event: 'reaction' }, ({ payload }) => {
        // Add reaction with animation
        const id = Math.random();
        setReactions(prev => [...prev, { ...payload, id }]);
        
        // Remove after animation
        setTimeout(() => {
          setReactions(prev => prev.filter(r => r.id !== id));
        }, 3000);
      })
      .subscribe();
 
    return () => {
      supabase.removeChannel(channel);
    };
  }, [postId, supabase]);
 
  const sendReaction = async (emoji) => {
    const channel = supabase.channel(`reactions:${postId}`);
    
    await channel.send({
      type: 'broadcast',
      event: 'reaction',
      payload: {
        emoji,
        user_id: currentUser.id,
        timestamp: Date.now()
      }
    });
  };
 
  return (
    <div>
      <div className="reaction-buttons">
        {['❤️', '👍', '🎉', '🔥'].map(emoji => (
          <button key={emoji} onClick={() => sendReaction(emoji)}>
            {emoji}
          </button>
        ))}
      </div>
      
      <div className="reactions-overlay">
        {reactions.map(reaction => (
          <div
            key={reaction.id}
            className="floating-reaction"
            style={{
              left: `${Math.random() * 100}%`,
              animation: 'float-up 3s ease-out'
            }}
          >
            {reaction.emoji}
          </div>
        ))}
      </div>
    </div>
  );
}

Real-World Patterns#

1. Chat Application#

typescript
// components/Chat.tsx
'use client';
 
import { useEffect, useState, useRef } from 'react';
import { createBrowserClient } from '@supabase/ssr';
 
interface Message {
  id: string;
  content: string;
  user_id: string;
  username: string;
  created_at: string;
}
 
export default function Chat({ channelId, currentUser }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [newMessage, setNewMessage] = useState('');
  const [onlineUsers, setOnlineUsers] = useState([]);
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  );
 
  useEffect(() => {
    // Fetch initial messages
    const fetchMessages = async () => {
      const { data } = await supabase
        .from('messages')
        .select('*')
        .eq('channel_id', channelId)
        .order('created_at', { ascending: true })
        .limit(50);
      
      setMessages(data || []);
    };
 
    fetchMessages();
 
    // Setup realtime channel
    const channel = supabase.channel(`chat:${channelId}`, {
      config: {
        presence: { key: currentUser.id }
      }
    });
 
    // Listen to new messages
    channel
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
          filter: `channel_id=eq.${channelId}`
        },
        (payload) => {
          setMessages(prev => [...prev, payload.new as Message]);
          scrollToBottom();
        }
      )
      // Track online users
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState();
        setOnlineUsers(Object.values(state).flat());
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          await channel.track({
            user_id: currentUser.id,
            username: currentUser.username,
            online_at: new Date().toISOString()
          });
        }
      });
 
    return () => {
      channel.untrack();
      supabase.removeChannel(channel);
    };
  }, [channelId, currentUser, supabase]);
 
  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };
 
  const sendMessage = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!newMessage.trim()) return;
 
    await supabase.from('messages').insert({
      content: newMessage,
      channel_id: channelId,
      user_id: currentUser.id,
      username: currentUser.username
    });
 
    setNewMessage('');
  };
 
  return (
    <div className="flex flex-col h-screen">
      {/* Online users */}
      <div className="p-4 border-b">
        <span className="text-sm text-gray-600">
          {onlineUsers.length} online
        </span>
      </div>
 
      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-4">
        {messages.map(msg => (
          <div
            key={msg.id}
            className={`mb-4 ${
              msg.user_id === currentUser.id ? 'text-right' : ''
            }`}
          >
            <div className="text-sm text-gray-600">{msg.username}</div>
            <div
              className={`inline-block p-3 rounded-lg ${
                msg.user_id === currentUser.id
                  ? 'bg-blue-500 text-white'
                  : 'bg-gray-200'
              }`}
            >
              {msg.content}
            </div>
          </div>
        ))}
        <div ref={messagesEndRef} />
      </div>
 
      {/* Input */}
      <form onSubmit={sendMessage} className="p-4 border-t">
        <input
          type="text"
          value={newMessage}
          onChange={(e) => setNewMessage(e.target.value)}
          placeholder="Type a message..."
          className="w-full p-2 border rounded"
        />
      </form>
    </div>
  );
}

2. Live Notifications#

typescript
// components/NotificationBell.tsx
'use client';
 
import { useEffect, useState } from 'react';
import { createBrowserClient } from '@supabase/ssr';
import { Bell } from 'lucide-react';
 
export default function NotificationBell({ userId }) {
  const [unreadCount, setUnreadCount] = useState(0);
  const [notifications, setNotifications] = useState([]);
  const [isOpen, setIsOpen] = useState(false);
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  );
 
  useEffect(() => {
    // Fetch initial unread count
    const fetchUnreadCount = async () => {
      const { count } = await supabase
        .from('notifications')
        .select('*', { count: 'exact', head: true })
        .eq('user_id', userId)
        .eq('read', false);
      
      setUnreadCount(count || 0);
    };
 
    fetchUnreadCount();
 
    // Subscribe to new notifications
    const channel = supabase
      .channel('notifications')
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'notifications',
          filter: `user_id=eq.${userId}`
        },
        (payload) => {
          setUnreadCount(prev => prev + 1);
          setNotifications(prev => [payload.new, ...prev]);
          
          // Show browser notification
          if ('Notification' in window && Notification.permission === 'granted') {
            new Notification(payload.new.title, {
              body: payload.new.message,
              icon: '/icon.png'
            });
          }
        }
      )
      .subscribe();
 
    return () => {
      supabase.removeChannel(channel);
    };
  }, [userId, supabase]);
 
  const markAsRead = async (notificationId) => {
    await supabase
      .from('notifications')
      .update({ read: true })
      .eq('id', notificationId);
    
    setUnreadCount(prev => Math.max(0, prev - 1));
  };
 
  return (
    <div className="relative">
      <button
        onClick={() => setIsOpen(!isOpen)}
        className="relative p-2"
      >
        <Bell className="w-6 h-6" />
        {unreadCount > 0 && (
          <span className="absolute top-0 right-0 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
            {unreadCount}
          </span>
        )}
      </button>
 
      {isOpen && (
        <div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border">
          <div className="p-4 border-b">
            <h3 className="font-semibold">Notifications</h3>
          </div>
          <div className="max-h-96 overflow-y-auto">
            {notifications.map(notif => (
              <div
                key={notif.id}
                className={`p-4 border-b hover:bg-gray-50 cursor-pointer ${
                  !notif.read ? 'bg-blue-50' : ''
                }`}
                onClick={() => markAsRead(notif.id)}
              >
                <div className="font-medium">{notif.title}</div>
                <div className="text-sm text-gray-600">{notif.message}</div>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

3. Collaborative Document Editing#

typescript
// components/CollaborativeEditor.tsx
'use client';
 
import { useEffect, useState, useRef } from 'react';
import { createBrowserClient } from '@supabase/ssr';
 
export default function CollaborativeEditor({ documentId, currentUser }) {
  const [content, setContent] = useState('');
  const [cursors, setCursors] = useState({});
  const [selections, setSelections] = useState({});
  const editorRef = useRef<HTMLTextAreaElement>(null);
  const supabase = createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  );
 
  useEffect(() => {
    // Fetch document
    const fetchDocument = async () => {
      const { data } = await supabase
        .from('documents')
        .select('content')
        .eq('id', documentId)
        .single();
      
      setContent(data?.content || '');
    };
 
    fetchDocument();
 
    const channel = supabase.channel(`doc:${documentId}`, {
      config: {
        presence: { key: currentUser.id }
      }
    });
 
    channel
      // Listen to content changes
      .on(
        'postgres_changes',
        {
          event: 'UPDATE',
          schema: 'public',
          table: 'documents',
          filter: `id=eq.${documentId}`
        },
        (payload) => {
          setContent(payload.new.content);
        }
      )
      // Track user presence
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState();
        const users = Object.values(state).flat();
        
        const newCursors = {};
        const newSelections = {};
        
        users.forEach(user => {
          if (user.user_id !== currentUser.id) {
            newCursors[user.user_id] = user.cursor;
            newSelections[user.user_id] = user.selection;
          }
        });
        
        setCursors(newCursors);
        setSelections(newSelections);
      })
      // Listen to cursor movements
      .on('broadcast', { event: 'cursor' }, ({ payload }) => {
        if (payload.user_id !== currentUser.id) {
          setCursors(prev => ({
            ...prev,
            [payload.user_id]: payload.position
          }));
        }
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          await channel.track({
            user_id: currentUser.id,
            username: currentUser.username,
            color: currentUser.color
          });
        }
      });
 
    return () => {
      channel.untrack();
      supabase.removeChannel(channel);
    };
  }, [documentId, currentUser, supabase]);
 
  const handleChange = async (e) => {
    const newContent = e.target.value;
    setContent(newContent);
 
    // Debounced save
    clearTimeout(window.saveTimeout);
    window.saveTimeout = setTimeout(async () => {
      await supabase
        .from('documents')
        .update({ content: newContent })
        .eq('id', documentId);
    }, 1000);
  };
 
  const handleSelectionChange = async () => {
    const channel = supabase.channel(`doc:${documentId}`);
    const start = editorRef.current?.selectionStart;
    const end = editorRef.current?.selectionEnd;
 
    await channel.send({
      type: 'broadcast',
      event: 'cursor',
      payload: {
        user_id: currentUser.id,
        position: start,
        selection: { start, end }
      }
    });
  };
 
  return (
    <div className="relative">
      <textarea
        ref={editorRef}
        value={content}
        onChange={handleChange}
        onSelect={handleSelectionChange}
        className="w-full h-screen p-4 font-mono"
      />
      
      {/* Show other users' cursors */}
      {Object.entries(cursors).map(([userId, position]) => (
        <div
          key={userId}
          className="absolute w-0.5 h-6 bg-blue-500"
          style={{
            top: `${Math.floor(position / 80) * 24}px`,
            left: `${(position % 80) * 9}px`
          }}
        />
      ))}
    </div>
  );
}

Conflict Resolution#

Two Realtime clients writing to the same row at nearly the same time will race. Pick a strategy based on how much correctness you need:

Last Write Wins#

The simplest approach — whichever update reaches Postgres last overwrites the previous value. Fine for low-contention data (a user profile, a draft note) where losing a rare concurrent edit is an acceptable tradeoff:

typescript
async function saveDocument(documentId: string, content: string) {
  const { data, error } = await supabase
    .from('documents')
    .update({
      content,
      updated_at: new Date().toISOString(),
    })
    .eq('id', documentId);
 
  return { data, error };
}

Optimistic Locking#

For anything where silently losing an edit is a real problem (collaborative documents, inventory counts), add a version column and make the update conditional on the version you read. If another writer already bumped the version, the UPDATE matches zero rows and you know there was a conflict:

typescript
async function saveDocumentWithLocking(
  documentId: string,
  content: string,
  currentVersion: number
) {
  const { data, error } = await supabase
    .from('documents')
    .update({
      content,
      version: currentVersion + 1,
      updated_at: new Date().toISOString(),
    })
    .eq('id', documentId)
    .eq('version', currentVersion) // only updates if version still matches
    .select();
 
  if (error || !data || data.length === 0) {
    // Version conflict — someone else wrote first. Reload and let the
    // caller decide how to merge or re-apply the edit.
    const { data: latest } = await supabase
      .from('documents')
      .select('*')
      .eq('id', documentId)
      .single();
 
    return { conflict: true, latest };
  }
 
  return { data, conflict: false };
}

For true multi-cursor collaborative editing (multiple people typing in the same paragraph simultaneously), optimistic locking is not enough — that needs an Operational Transformation or CRDT library (e.g. Yjs) layered on top of Realtime as the transport.

Performance Optimization#

1. Channel Multiplexing#

typescript
// Reuse the same channel for multiple subscriptions
const channel = supabase.channel(`room:${roomId}`);
 
channel
  .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, handleMessage)
  .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'reactions' }, handleReaction)
  .on('presence', { event: 'sync' }, handlePresence)
  .on('broadcast', { event: 'cursor' }, handleCursor)
  .subscribe();

2. Throttling Updates#

typescript
import { throttle } from 'lodash';
 
const sendCursorUpdate = throttle(async (x, y) => {
  await channel.send({
    type: 'broadcast',
    event: 'cursor',
    payload: { x, y }
  });
}, 100); // Max 10 updates per second

3. Cleanup and Memory Management#

typescript
useEffect(() => {
  const channel = supabase.channel(`chat:${channelId}`);
  
  // ... setup subscriptions
  
  return () => {
    // Always cleanup
    channel.untrack();
    supabase.removeChannel(channel);
  };
}, [channelId]);

Troubleshooting#

Common Issues#

  1. Not receiving updates

    • Check RLS policies
    • Verify the table is in the supabase_realtime publication
    • Ensure channel is subscribed
  2. Too many connections

    • Reuse channels when possible
    • Implement connection pooling
    • Use channel multiplexing
  3. Delayed updates

    • Check network latency
    • Optimize database queries
    • Use broadcast for ephemeral data

Best Practices#

  1. Always cleanup subscriptions in useEffect return
  2. Use RLS policies to secure realtime data
  3. Throttle frequent updates (cursor movements, typing)
  4. Handle reconnection gracefully
  5. Test with multiple clients to catch race conditions
  6. Monitor channel status and show connection state to users

Conclusion#

Supabase Realtime makes building live, collaborative features straightforward. Key takeaways:

  • Use Postgres Changes for database updates
  • Use Presence for user state tracking
  • Use Broadcast for ephemeral messages
  • Always implement proper RLS policies
  • Clean up subscriptions to prevent memory leaks

Last updated: 21 October 2026

Further reading: Next.js + Supabase: 10 Production Mistakes + Fixes (2026), Supabase Multi-Tenant Architecture: Best Practices 2026.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.