Supabase subscriptions use Realtime channels to stream data changes from your database to connected clients. Subscribe to a channel with supabase.channel(), listen for postgres_changes events (INSERT, UPDATE, DELETE), and filter by table, schema, or column values. Supabase Realtime also supports Broadcast for client-to-client messaging and Presence for tracking online users. Always add tables to the realtime publication and clean up subscriptions to prevent memory leaks.
Understanding and Using Supabase Realtime Subscriptions
Supabase Realtime provides three features through a unified channel API: Postgres Changes (streaming database mutations), Broadcast (client-to-client messaging), and Presence (online status tracking). This tutorial explains how each works, walks through subscribing to database changes, filtering events, and managing the subscription lifecycle in frontend applications.
Prerequisites
- A Supabase project with at least one table
- @supabase/supabase-js v2+ installed
- Basic understanding of WebSockets and event-driven patterns
- RLS SELECT policy on tables you want to subscribe to
Step-by-step guide
Add the table to the realtime publication
Add the table to the realtime publication
Before you can subscribe to changes on a table, it must be added to the supabase_realtime publication. This is a PostgreSQL replication feature that controls which tables stream changes to the Realtime server. You can add tables via the SQL Editor or the Supabase Dashboard under Database > Replication. Without this step, your subscription will not receive any events.
1-- Add a table to the realtime publication2alter publication supabase_realtime add table messages;34-- Add multiple tables5alter publication supabase_realtime add table messages, reactions, typing_indicators;67-- Verify which tables are in the publication8select * from pg_publication_tables9where pubname = 'supabase_realtime';Expected result: The table is registered with the realtime publication and will stream INSERT, UPDATE, and DELETE events to subscribers.
Subscribe to all changes on a table
Subscribe to all changes on a table
Use supabase.channel() to create a named channel, then call .on('postgres_changes', ...) to listen for events. The event parameter can be '*' for all events, or 'INSERT', 'UPDATE', or 'DELETE' for specific operations. The callback receives a payload with the event type, old record (for updates and deletes), and new record (for inserts and updates). Call .subscribe() to start listening.
1import { supabase } from './lib/supabase'23const channel = supabase4 .channel('messages-all')5 .on(6 'postgres_changes',7 { event: '*', schema: 'public', table: 'messages' },8 (payload) => {9 console.log('Event:', payload.eventType)10 console.log('New record:', payload.new)11 console.log('Old record:', payload.old)1213 switch (payload.eventType) {14 case 'INSERT':15 // Add new message to UI16 break17 case 'UPDATE':18 // Update existing message in UI19 break20 case 'DELETE':21 // Remove message from UI22 break23 }24 }25 )26 .subscribe()Expected result: The subscription receives real-time events for every INSERT, UPDATE, and DELETE on the messages table.
Filter events by specific operations or column values
Filter events by specific operations or column values
You can subscribe to specific event types by passing 'INSERT', 'UPDATE', or 'DELETE' instead of '*'. You can also filter by column values using the filter parameter. This reduces the number of events sent to the client, saving bandwidth and processing time. Chain multiple .on() calls on the same channel to listen for different events with different callbacks.
1const channel = supabase2 .channel('messages-filtered')3 // Only listen for new messages4 .on(5 'postgres_changes',6 { event: 'INSERT', schema: 'public', table: 'messages' },7 (payload) => {8 console.log('New message:', payload.new)9 }10 )11 // Listen for updates to a specific room12 .on(13 'postgres_changes',14 {15 event: 'UPDATE',16 schema: 'public',17 table: 'messages',18 filter: 'room_id=eq.123',19 },20 (payload) => {21 console.log('Message updated in room 123:', payload.new)22 }23 )24 .subscribe()Expected result: The subscription only receives INSERT events for all messages and UPDATE events filtered to room_id 123.
Use Broadcast for client-to-client messaging
Use Broadcast for client-to-client messaging
Broadcast lets clients send messages to each other through a shared channel without involving the database. This is useful for typing indicators, cursor positions, and other ephemeral state that does not need to be persisted. Broadcast events go through the Realtime server and are delivered to all subscribers on the same channel.
1const channel = supabase.channel('room:lobby')23// Listen for broadcast events4channel.on('broadcast', { event: 'typing' }, (payload) => {5 console.log(`${payload.payload.user} is typing...`)6})78channel.subscribe(async (status) => {9 if (status === 'SUBSCRIBED') {10 // Send a broadcast event11 await channel.send({12 type: 'broadcast',13 event: 'typing',14 payload: { user: 'Alice', isTyping: true },15 })16 }17})Expected result: All clients subscribed to the room:lobby channel receive the typing event with the user's name.
Track online users with Presence
Track online users with Presence
Presence lets you track which users are currently online and their state (e.g., their cursor position or status). When a client subscribes and calls track(), their presence is visible to all other subscribers. The sync event fires whenever the presence state changes. Join and leave events fire for individual users connecting or disconnecting.
1const channel = supabase.channel('room:lobby')23channel4 .on('presence', { event: 'sync' }, () => {5 const state = channel.presenceState()6 console.log('All online users:', state)7 })8 .on('presence', { event: 'join' }, ({ key, newPresences }) => {9 console.log('User joined:', key, newPresences)10 })11 .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {12 console.log('User left:', key, leftPresences)13 })14 .subscribe(async (status) => {15 if (status === 'SUBSCRIBED') {16 await channel.track({17 user: 'Alice',18 online_at: new Date().toISOString(),19 })20 }21 })Expected result: All clients in the channel can see who is currently online. Join and leave events fire when users connect or disconnect.
Clean up subscriptions properly
Clean up subscriptions properly
Always remove channels when they are no longer needed. In React, do this in the useEffect cleanup function. In Vue, use onUnmounted. Failing to clean up subscriptions causes memory leaks and accumulates WebSocket connections, which can degrade performance and hit Supabase's concurrent connection limits.
1// React cleanup2import { useEffect } from 'react'3import { supabase } from './lib/supabase'45function ChatRoom({ roomId }: { roomId: string }) {6 useEffect(() => {7 const channel = supabase8 .channel(`room-${roomId}`)9 .on('postgres_changes',10 { event: 'INSERT', schema: 'public', table: 'messages',11 filter: `room_id=eq.${roomId}` },12 (payload) => { /* handle new message */ }13 )14 .subscribe()1516 // Cleanup when component unmounts or roomId changes17 return () => {18 supabase.removeChannel(channel)19 }20 }, [roomId])2122 return <div>Chat messages here</div>23}2425// Remove all channels at once (e.g., on logout)26supabase.removeAllChannels()Expected result: Subscriptions are properly cleaned up on unmount, preventing memory leaks and stale event handlers.
Complete working example
1// Custom React hook for real-time message subscription2import { useEffect, useState, useCallback } from 'react'3import { supabase } from '../lib/supabase'45interface Message {6 id: number7 content: string8 user_id: string9 room_id: string10 created_at: string11}1213export function useRealtimeMessages(roomId: string) {14 const [messages, setMessages] = useState<Message[]>([])15 const [loading, setLoading] = useState(true)1617 // Fetch initial messages18 const fetchMessages = useCallback(async () => {19 const { data, error } = await supabase20 .from('messages')21 .select('*')22 .eq('room_id', roomId)23 .order('created_at', { ascending: true })24 .limit(100)2526 if (!error) setMessages(data || [])27 setLoading(false)28 }, [roomId])2930 useEffect(() => {31 fetchMessages()3233 // Subscribe to new messages in this room34 const channel = supabase35 .channel(`room-${roomId}`)36 .on('postgres_changes',37 {38 event: 'INSERT',39 schema: 'public',40 table: 'messages',41 filter: `room_id=eq.${roomId}`,42 },43 (payload) => {44 setMessages((prev) => [...prev, payload.new as Message])45 }46 )47 .on('postgres_changes',48 {49 event: 'DELETE',50 schema: 'public',51 table: 'messages',52 filter: `room_id=eq.${roomId}`,53 },54 (payload) => {55 setMessages((prev) => prev.filter((m) => m.id !== payload.old.id))56 }57 )58 .subscribe()5960 return () => {61 supabase.removeChannel(channel)62 }63 }, [roomId, fetchMessages])6465 // Send a message66 const sendMessage = async (content: string, userId: string) => {67 const { error } = await supabase68 .from('messages')69 .insert({ content, user_id: userId, room_id: roomId })70 return { error }71 }7273 return { messages, loading, sendMessage }74}Common mistakes when using Subscriptions in Supabase
Why it's a problem: Forgetting to add the table to the supabase_realtime publication, resulting in no events being received
How to avoid: Run ALTER PUBLICATION supabase_realtime ADD TABLE your_table; in the SQL Editor. Without this, the Realtime server does not know to stream changes for this table.
Why it's a problem: Not cleaning up subscriptions on component unmount, causing memory leaks and duplicate event handlers
How to avoid: Always call supabase.removeChannel(channel) in the cleanup function of useEffect (React) or onUnmounted (Vue). This closes the WebSocket and removes event listeners.
Why it's a problem: Missing a SELECT RLS policy, which prevents the Realtime server from sending events to the client
How to avoid: Realtime respects RLS. The authenticated user must have a SELECT policy on the table to receive change events. Create a policy that allows the user to read the rows they should see in real-time.
Why it's a problem: Using the same channel name for multiple subscriptions, which overwrites the previous subscription
How to avoid: Use unique channel names for each subscription. A good pattern is to include the table name and any filter values: 'room-123-messages'.
Best practices
- Always add tables to the supabase_realtime publication before subscribing to changes
- Use unique, descriptive channel names that include the context (e.g., 'room-123-messages')
- Filter events by column values when possible to reduce bandwidth and processing on the client
- Clean up subscriptions in useEffect cleanup (React) or onUnmounted (Vue) to prevent memory leaks
- Combine initial data fetching with real-time subscriptions for a complete, responsive UI
- Use Broadcast for ephemeral state like typing indicators instead of writing to the database
- Ensure RLS SELECT policies exist for tables you subscribe to — Realtime respects RLS
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I want to build a real-time chat feature in my React app using Supabase. Show me how to subscribe to new messages in a specific room, display them in a list, and properly clean up the subscription when the component unmounts.
Set up a Supabase Realtime subscription on my messages table filtered by room_id. Include Broadcast for typing indicators and Presence for tracking online users. Show the SQL to add the table to the realtime publication and the JavaScript code for all three Realtime features.
Frequently asked questions
What is the difference between Postgres Changes, Broadcast, and Presence?
Postgres Changes streams database mutations (INSERT, UPDATE, DELETE) to subscribers. Broadcast sends arbitrary messages between clients without touching the database. Presence tracks which users are currently online. All three use the same channel API.
Does Supabase Realtime respect RLS policies?
Yes. The Realtime server checks the authenticated user's RLS policies before sending events. If the user does not have a SELECT policy on the table, they will not receive any change events.
How many concurrent real-time connections can I have?
The free plan supports up to 200 concurrent connections. Pro supports 500, and Team supports 1,000. Each browser tab or device counts as one connection. You can increase limits on higher plans.
Why am I not receiving DELETE events?
DELETE events only include the old record's primary key by default. Ensure your table has REPLICA IDENTITY set to FULL if you need the complete old record: ALTER TABLE messages REPLICA IDENTITY FULL;
Can I subscribe to changes across multiple tables on one channel?
Yes, chain multiple .on('postgres_changes', ...) calls on the same channel with different table parameters. Each call can listen to a different table with its own event type and filter.
Are real-time events guaranteed to be delivered?
No. Real-time events are best-effort. If a client disconnects momentarily, it may miss events. Always fetch the latest data on reconnect to fill any gaps. Combine real-time with initial data fetching for reliability.
Can RapidDev help implement real-time features in my Supabase application?
Yes, RapidDev can design and implement real-time features including live chat, collaborative editing, presence indicators, and notification systems using Supabase Realtime.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation