Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Use Subscriptions in Supabase

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.

What you'll learn

  • How channels, Postgres Changes, Broadcast, and Presence work in Supabase Realtime
  • How to subscribe to INSERT, UPDATE, and DELETE events on a table
  • How to filter real-time events by column values for targeted updates
  • How to properly clean up subscriptions to prevent memory leaks
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner8 min read15-20 minSupabase (all plans), @supabase/supabase-js v2+, Supabase RealtimeMarch 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1-- Add a table to the realtime publication
2alter publication supabase_realtime add table messages;
3
4-- Add multiple tables
5alter publication supabase_realtime add table messages, reactions, typing_indicators;
6
7-- Verify which tables are in the publication
8select * from pg_publication_tables
9where pubname = 'supabase_realtime';

Expected result: The table is registered with the realtime publication and will stream INSERT, UPDATE, and DELETE events to subscribers.

2

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.

typescript
1import { supabase } from './lib/supabase'
2
3const channel = supabase
4 .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)
12
13 switch (payload.eventType) {
14 case 'INSERT':
15 // Add new message to UI
16 break
17 case 'UPDATE':
18 // Update existing message in UI
19 break
20 case 'DELETE':
21 // Remove message from UI
22 break
23 }
24 }
25 )
26 .subscribe()

Expected result: The subscription receives real-time events for every INSERT, UPDATE, and DELETE on the messages table.

3

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.

typescript
1const channel = supabase
2 .channel('messages-filtered')
3 // Only listen for new messages
4 .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 room
12 .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.

4

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.

typescript
1const channel = supabase.channel('room:lobby')
2
3// Listen for broadcast events
4channel.on('broadcast', { event: 'typing' }, (payload) => {
5 console.log(`${payload.payload.user} is typing...`)
6})
7
8channel.subscribe(async (status) => {
9 if (status === 'SUBSCRIBED') {
10 // Send a broadcast event
11 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.

5

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.

typescript
1const channel = supabase.channel('room:lobby')
2
3channel
4 .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.

6

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.

typescript
1// React cleanup
2import { useEffect } from 'react'
3import { supabase } from './lib/supabase'
4
5function ChatRoom({ roomId }: { roomId: string }) {
6 useEffect(() => {
7 const channel = supabase
8 .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()
15
16 // Cleanup when component unmounts or roomId changes
17 return () => {
18 supabase.removeChannel(channel)
19 }
20 }, [roomId])
21
22 return <div>Chat messages here</div>
23}
24
25// 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

src/hooks/useRealtimeMessages.ts
1// Custom React hook for real-time message subscription
2import { useEffect, useState, useCallback } from 'react'
3import { supabase } from '../lib/supabase'
4
5interface Message {
6 id: number
7 content: string
8 user_id: string
9 room_id: string
10 created_at: string
11}
12
13export function useRealtimeMessages(roomId: string) {
14 const [messages, setMessages] = useState<Message[]>([])
15 const [loading, setLoading] = useState(true)
16
17 // Fetch initial messages
18 const fetchMessages = useCallback(async () => {
19 const { data, error } = await supabase
20 .from('messages')
21 .select('*')
22 .eq('room_id', roomId)
23 .order('created_at', { ascending: true })
24 .limit(100)
25
26 if (!error) setMessages(data || [])
27 setLoading(false)
28 }, [roomId])
29
30 useEffect(() => {
31 fetchMessages()
32
33 // Subscribe to new messages in this room
34 const channel = supabase
35 .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()
59
60 return () => {
61 supabase.removeChannel(channel)
62 }
63 }, [roomId, fetchMessages])
64
65 // Send a message
66 const sendMessage = async (content: string, userId: string) => {
67 const { error } = await supabase
68 .from('messages')
69 .insert({ content, user_id: userId, room_id: roomId })
70 return { error }
71 }
72
73 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.

ChatGPT Prompt

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.

Supabase Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.