# How to Set Up Real-Time Listeners in Supabase

- Tool: Supabase
- Difficulty: Beginner
- Time required: 10-15 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+
- Last updated: March 2026

## TL;DR

To set up real-time listeners in Supabase, first add your table to the realtime publication with ALTER PUBLICATION supabase_realtime ADD TABLE your_table. Then subscribe to changes using supabase.channel().on('postgres_changes', ...).subscribe(). You can listen for INSERT, UPDATE, DELETE, or all events, and filter by specific column values. Always clean up subscriptions with removeChannel() when the component unmounts to prevent memory leaks.

## Subscribing to Real-Time Database Changes in Supabase

Supabase Realtime lets your application receive database changes as they happen, without polling. When a row is inserted, updated, or deleted, all connected clients receive the change instantly via WebSocket. This tutorial covers enabling real-time on your tables, subscribing to specific events, filtering by column values, and properly managing subscription lifecycles in React and other frameworks.

## Before you start

- A Supabase project with at least one table
- @supabase/supabase-js v2+ installed in your project
- RLS enabled with SELECT policies on the table (real-time respects RLS)
- Basic understanding of WebSockets (conceptual, no direct WebSocket code needed)

## Step-by-step guide

### 1. Add your table to the realtime publication

Supabase does not stream changes for all tables by default. You must explicitly add each table to the supabase_realtime publication. This is a one-time setup per table. You can do this in the SQL Editor in the Dashboard. Without this step, subscriptions will connect but never receive any events.

```
-- Add a single table to the realtime publication
ALTER PUBLICATION supabase_realtime ADD TABLE messages;

-- Add multiple tables at once
ALTER PUBLICATION supabase_realtime ADD TABLE messages, notifications, chat_rooms;

-- Verify which tables are in the publication
SELECT * FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
```

**Expected result:** The table appears in the supabase_realtime publication and changes will be streamed to connected clients.

### 2. Subscribe to all changes on a table

Create a channel and subscribe to postgres_changes events. Use event: '*' to listen for all change types (INSERT, UPDATE, DELETE). The callback receives a payload object containing the event type, the new row (for INSERT/UPDATE), and the old row (for DELETE). The schema parameter should be 'public' unless your table is in a different schema.

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

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

// Subscribe to all changes on the messages table
const channel = supabase
  .channel('messages-all-changes')
  .on(
    'postgres_changes',
    { event: '*', schema: 'public', table: 'messages' },
    (payload) => {
      console.log('Event type:', payload.eventType)
      console.log('New data:', payload.new)
      console.log('Old data:', payload.old)
    }
  )
  .subscribe((status) => {
    console.log('Subscription status:', status)
    // SUBSCRIBED, CHANNEL_ERROR, TIMED_OUT, CLOSED
  })
```

**Expected result:** The callback fires whenever any row in the messages table is inserted, updated, or deleted.

### 3. Subscribe to specific event types

Instead of listening to all events, you can subscribe to specific operations. Chain multiple .on() calls on the same channel to handle each event type differently. This is useful when INSERT and DELETE require different UI updates.

```
const channel = supabase
  .channel('messages-specific')
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'messages' },
    (payload) => {
      console.log('New message:', payload.new)
      // Append to message list
    }
  )
  .on(
    'postgres_changes',
    { event: 'UPDATE', schema: 'public', table: 'messages' },
    (payload) => {
      console.log('Updated message:', payload.new)
      // Replace in message list
    }
  )
  .on(
    'postgres_changes',
    { event: 'DELETE', schema: 'public', table: 'messages' },
    (payload) => {
      console.log('Deleted message ID:', payload.old.id)
      // Remove from message list
    }
  )
  .subscribe()
```

**Expected result:** Each event type triggers its own callback with the appropriate payload.

### 4. Filter events by column values

For tables with many rows, you often want to receive changes only for specific records — such as messages in a particular chat room. Use the filter parameter to subscribe only to changes where a column matches a specific value. This reduces bandwidth and processing on the client.

```
// Only receive messages for a specific chat room
const roomId = 'room-123'

const channel = supabase
  .channel(`room-${roomId}`)
  .on(
    'postgres_changes',
    {
      event: '*',
      schema: 'public',
      table: 'messages',
      filter: `room_id=eq.${roomId}`,
    },
    (payload) => {
      console.log('Message in room:', payload.new)
    }
  )
  .subscribe()

// Filter supports eq, neq, gt, gte, lt, lte, in
// Examples:
// filter: 'status=eq.active'
// filter: 'priority=gt.5'
// filter: 'type=in.(message,notification)'
```

**Expected result:** The client only receives real-time events for rows matching the filter condition.

### 5. Use real-time listeners in a React component

In React, set up subscriptions inside useEffect and clean them up in the cleanup function. This ensures the subscription is created when the component mounts and removed when it unmounts. Always call supabase.removeChannel() in the cleanup to prevent memory leaks and duplicate event handlers.

```
import { useEffect, useState } from 'react'

function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<any[]>([])

  // Fetch initial messages
  useEffect(() => {
    supabase
      .from('messages')
      .select('*')
      .eq('room_id', roomId)
      .order('created_at', { ascending: true })
      .then(({ data }) => { if (data) setMessages(data) })
  }, [roomId])

  // Subscribe to real-time changes
  useEffect(() => {
    const channel = supabase
      .channel(`room-${roomId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
          filter: `room_id=eq.${roomId}`,
        },
        (payload) => {
          setMessages((prev) => [...prev, payload.new])
        }
      )
      .subscribe()

    // Cleanup on unmount or roomId change
    return () => {
      supabase.removeChannel(channel)
    }
  }, [roomId])

  return (
    <div>
      {messages.map((msg) => (
        <p key={msg.id}>{msg.content}</p>
      ))}
    </div>
  )
}
```

**Expected result:** The chat room displays messages in real time. Switching rooms cleans up the old subscription and creates a new one.

## Complete code example

File: `realtime-messages.ts`

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

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

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

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

  // Fetch existing messages
  useEffect(() => {
    supabase
      .from('messages')
      .select('*')
      .eq('room_id', roomId)
      .order('created_at', { ascending: true })
      .then(({ data, error }) => {
        if (data) setMessages(data)
        if (error) console.error('Fetch error:', error.message)
      })
  }, [roomId])

  // Subscribe to real-time changes
  useEffect(() => {
    const channel: RealtimeChannel = supabase
      .channel(`room-${roomId}`)
      .on('postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'messages',
          filter: `room_id=eq.${roomId}` },
        (payload) => {
          setMessages((prev) => [...prev, payload.new as Message])
        }
      )
      .on('postgres_changes',
        { event: 'UPDATE', schema: 'public', table: 'messages',
          filter: `room_id=eq.${roomId}` },
        (payload) => {
          setMessages((prev) =>
            prev.map((m) => m.id === payload.new.id ? payload.new as Message : m)
          )
        }
      )
      .on('postgres_changes',
        { event: 'DELETE', schema: 'public', table: 'messages',
          filter: `room_id=eq.${roomId}` },
        (payload) => {
          setMessages((prev) => prev.filter((m) => m.id !== payload.old.id))
        }
      )
      .subscribe((s) => setStatus(s))

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

  const sendMessage = useCallback(async (content: string, userId: string) => {
    const { error } = await supabase
      .from('messages')
      .insert({ room_id: roomId, user_id: userId, content })
    if (error) console.error('Send error:', error.message)
  }, [roomId])

  return { messages, status, sendMessage }
}
```

## Common mistakes

- **Not adding the table to the supabase_realtime publication** — undefined Fix: Run ALTER PUBLICATION supabase_realtime ADD TABLE your_table in the SQL Editor. Subscriptions connect but receive no events without this.
- **Not cleaning up subscriptions when a component unmounts** — undefined Fix: Always call supabase.removeChannel(channel) in the useEffect cleanup function. Without it, you get duplicate event handlers and memory leaks.
- **Missing RLS SELECT policy on the table, causing real-time events to be silently dropped** — undefined Fix: Real-time respects RLS. The subscribed user must have a SELECT policy on the table. Add a policy that allows authenticated users to read the relevant rows.
- **Expecting DELETE payload.new to contain data** — undefined Fix: For DELETE events, payload.new is empty. The deleted row data is in payload.old. Set REPLICA IDENTITY FULL on the table if you need all columns in the old payload.

## Best practices

- Always add tables to the supabase_realtime publication before subscribing to changes
- Use descriptive, unique channel names that include the table and filter context
- Clean up subscriptions in useEffect cleanup functions to prevent memory leaks
- Use server-side filters (filter parameter) instead of client-side filtering to reduce WebSocket traffic
- Set REPLICA IDENTITY FULL on tables where you need the full row data in DELETE event payloads
- Fetch initial data before subscribing to real-time changes to avoid missing events during the gap
- Handle the CHANNEL_ERROR and TIMED_OUT subscription statuses to implement reconnection logic

## Frequently asked questions

### Does Supabase real-time work with RLS?

Yes. Real-time respects Row Level Security. The user's JWT is used to evaluate RLS policies, and only events for rows the user has SELECT access to are delivered. If real-time is not delivering events, check your SELECT policy.

### How many real-time connections can I have?

Supabase allows up to 200 concurrent connections on the Free plan, 500 on Pro, and more on higher plans. Each browser tab with a subscription counts as one connection.

### Can I subscribe to changes across multiple tables in one channel?

Yes. Chain multiple .on('postgres_changes', ...) calls on the same channel, each with a different table. All events flow through the single WebSocket connection.

### Why am I not receiving DELETE events with full row data?

By default, DELETE events only include the primary key in payload.old. To receive all columns, set REPLICA IDENTITY FULL on the table: ALTER TABLE messages REPLICA IDENTITY FULL. This increases WAL size slightly.

### What happens if the WebSocket connection drops?

The Supabase client automatically attempts to reconnect. During the disconnection, events are lost. Fetch the latest data when the subscription status changes back to SUBSCRIBED to catch up on missed events.

### Is real-time suitable for high-frequency updates?

For very high-frequency updates (hundreds per second), use Broadcast instead of Postgres Changes. Broadcast is client-to-client via the Realtime server and does not go through the database, making it significantly faster.

### Can RapidDev help build real-time features with Supabase?

Yes. RapidDev can architect and implement real-time features including chat systems, live dashboards, collaborative editing, and notification systems using Supabase Realtime.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-set-up-real-time-listeners-in-supabase
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-set-up-real-time-listeners-in-supabase
