Supabase

Supabase Realtime Not Receiving Events: Complete Fix

A Supabase Realtime subscription can say SUBSCRIBED and still receive nothing. This fix walks through RLS, filter syntax, stale React subscriptions, paused projects, and broadcast versus postgres_changes confusion.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
8 min read

Introduction#

If Supabase Realtime connects but does not receive events, check RLS before you touch the WebSocket code. A SUBSCRIBED status only means the channel joined; it does not prove your Postgres change is visible to that client.

For related Supabase production issues, keep Supabase Realtime Complete Guide, Supabase RLS silent failures, and Next.js Supabase SSR session management nearby.

Real Reports This Fix Is Based On#

1. Check RLS First#

Realtime respects the permissions of the subscribing user. If the user cannot select the row after the change, the change may not be delivered to that user.

Start with a plain select using the same client:

ts
const { data, error } = await supabase
  .from("messages")
  .select("id, room_id, body")
  .eq("room_id", roomId)
  .limit(1);
 
console.log({ data, error });

If this returns an empty array or an RLS error, your subscription is not the first problem. Fix the select policy.

sql
create policy "room members can read messages"
on public.messages
for select
to authenticated
using (
  exists (
    select 1
    from public.room_members
    where room_members.room_id = messages.room_id
      and room_members.user_id = auth.uid()
  )
);
Don't
Only creating an INSERT policy and expecting Realtime to deliver rows
Do
Create a SELECT policy for the subscribing user too
The Fix

The fix in one line: if a normal .select() cannot see the row, Realtime will not be your reliable escape hatch.

2. Use Exact postgres_changes Filter Syntax#

The current supabase-js v2 API uses channels and postgres_changes. The filter object is not a string like old .from().on() examples.

ts
const channel = supabase
  .channel(`room:${roomId}`)
  .on(
    "postgres_changes",
    {
      event: "INSERT",
      schema: "public",
      table: "messages",
      filter: `room_id=eq.${roomId}`,
    },
    (payload) => {
      console.log("new message", payload.new);
    }
  )
  .subscribe((status) => {
    console.log("realtime status", status);
  });

Do not write schema:public,table:messages as one string. Use separate keys:

ts
{
  event: "*",
  schema: "public",
  table: "messages"
}

If table-specific events fail but schema-wide events work, check spelling, casing, publication settings, and whether you are actually updating the table you subscribed to. The GitHub issue above had exactly that class of confusion: table filtering changed the behavior.

3. Confirm The Table Is In The Realtime Publication#

Realtime does not watch every table by magic. In the Supabase dashboard, enable Realtime for the table, or run SQL that adds it to the supabase_realtime publication.

sql
alter publication supabase_realtime add table public.messages;

Then verify:

sql
select *
from pg_publication_tables
where pubname = 'supabase_realtime'
  and schemaname = 'public'
  and tablename = 'messages';

If you need delete payloads with previous values, also check replica identity:

sql
alter table public.messages replica identity full;

Use this only where you need it. Full replica identity can increase WAL size.

4. Clean Up React Subscriptions Correctly#

In React and Next.js, stale subscriptions are the second most common problem after RLS. A component mounts, joins a channel, route params change, the old channel stays alive, and now you are debugging duplicate or closed subscriptions.

Use a stable cleanup:

tsx
"use client";
 
import { useEffect } from "react";
 
export function RoomMessages({ supabase, roomId }) {
  useEffect(() => {
    const channel = supabase
      .channel(`room:${roomId}`)
      .on(
        "postgres_changes",
        {
          event: "INSERT",
          schema: "public",
          table: "messages",
          filter: `room_id=eq.${roomId}`,
        },
        (payload) => {
          console.log(payload.new);
        }
      )
      .subscribe();
 
    return () => {
      supabase.removeChannel(channel);
    };
  }, [supabase, roomId]);
 
  return null;
}

The dependency array matters. If roomId changes, you want the old channel removed and the new one created. If your Supabase client is recreated on every render, memoize it higher in the tree.

If you only see the duplicate subscription in development, that is React Strict Mode stress-testing your cleanup — not a bug. The fix for useEffect running twice in React 18 walks through AbortController, useRef guards, and Supabase Realtime subscribe() with no unsubscribe() leaking listeners.

5. Reconnect After Project Pause Or Mobile Background#

Free projects can pause. Mobile apps can background. Browsers can sleep tabs. A channel that was healthy yesterday may not resume with the auth state you expect.

Add visible status logging:

ts
const channel = supabase.channel("debug-room");
 
channel.subscribe((status, error) => {
  console.log("realtime", { status, error });
});

For a custom JWT, initialise Realtime authentication before subscribing. The Postgres Changes documentation shows this ordering; accessToken below is the valid token obtained by your authentication flow:

ts
await supabase.realtime.setAuth(accessToken);
const channel = supabase
  .channel("authenticated-messages")
  .on("postgres_changes", {
    event: "INSERT",
    schema: "public",
    table: "messages",
  }, console.log)
  .subscribe((status, error) => console.log(status, error));

Do not treat an auth-change callback as a universal reconnect fix. In supabase-js issue 1730, reporters describe a session-restoration race involving subscription timing. Confirm which token is available when the channel joins, then test an authorised change after SUBSCRIBED.

6. Do Not Confuse Broadcast With Postgres Changes#

Supabase has multiple Realtime features:

  • postgres_changes listens to database changes.
  • broadcast sends app-level messages.
  • presence tracks online state.

This listens to database inserts:

ts
supabase.channel("db")
  .on("postgres_changes", {
    event: "INSERT",
    schema: "public",
    table: "messages",
  }, console.log)
  .subscribe();

This sends an app event after joining and also delivers it to the sender, using the documented self-send option:

ts
const channel = supabase.channel("room", {
  config: { broadcast: { self: true } },
});
 
channel
  .on("broadcast", { event: "typing" }, (payload) => {
    console.log(payload);
  })
  .subscribe((status) => {
    if (status === "SUBSCRIBED") {
      channel.send({
        type: "broadcast",
        event: "typing",
        payload: { userId },
      });
    }
  });

Calling send() does not insert an application row. An ordinary insert does not emit your custom Broadcast event unless you have configured a database Broadcast trigger. Use postgres_changes for the publication-based examples in this article.

7. Create One Manual Database Change#

When the app has optimistic UI, server actions, retries, and background jobs, it is easy to test the wrong thing. Make one manual database change after the channel subscribes.

Open the SQL editor and run:

sql
insert into public.messages (room_id, user_id, body)
values (
  '00000000-0000-0000-0000-000000000001',
  '00000000-0000-0000-0000-000000000002',
  'realtime test'
);

Then watch the browser console. If the manual insert arrives, your Realtime setup works and your app mutation path is suspect. If the manual insert does not arrive, keep debugging publication, RLS, filters, auth token, and cleanup.

For updates, change exactly one row:

sql
update public.messages
set body = body || ' updated'
where id = '00000000-0000-0000-0000-000000000003';

This removes a whole category of false leads, especially when a server action updates a different table than the one the client is watching.

8. Log The Payload Shape Before Updating State#

Another quiet failure: the event arrives, but the React state update ignores it because the handler expects the wrong payload.

ts
.on("postgres_changes", config, (payload) => {
  console.log("raw realtime payload", payload);
 
  if (payload.eventType === "INSERT") {
    setMessages((current) => [...current, payload.new]);
  }
 
  if (payload.eventType === "DELETE") {
    setMessages((current) =>
      current.filter((message) => message.id !== payload.old.id)
    );
  }
});

For DELETE, you may need replica identity to get the old row fields you expect. For UPDATE, compare payload.old and payload.new before assuming the event did not fire.

When this won't work
  • If Realtime is not enabled for the table, no client-side code can receive table changes.
  • If RLS blocks select for the changed row, the subscribing user may receive nothing.
  • If the Supabase project is paused or sleeping, reconnect after it wakes up.

9. Confirm You're Subscribing From a Client Component#

A subscription that updates the browser's React state belongs in a Client Component effect. WebSocket is also available in Node.js: a server-side subscription can receive events, but its callback runs on the server and does not update an already rendered browser component. A request-scoped Server Component render is also the wrong lifecycle for a persistent subscription.

Fetch the initial rows on the server and pass the serialisable rows to your client UI. The following component receives those rows and a stable browser Supabase client from a client parent. Do not pass a Supabase client instance across the server/client boundary.

jsx
// LivePosts.jsx — subscribes to new rows only
'use client'
 
import { useEffect, useState } from 'react'
 
export function LivePosts({ initialPosts = [], supabase }) {
  const [posts, setPosts] = useState(initialPosts)
 
  useEffect(() => {
    const channel = supabase
      .channel('posts')
      .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'posts' }, (payload) => {
        setPosts((current) => [...current, payload.new])
      })
      .subscribe()
 
    return () => { supabase.removeChannel(channel) }
  }, [supabase])
 
  return <div>{posts.map((p) => <p key={p.id}>{p.title}</p>)}</div>
}

This minimal handler appends newly inserted rows only. Updating or deleting existing rows requires a separate handler keyed by the row ID; appending payload.new for every event would duplicate updated rows and mishandle deletions.

Summary#

  • Test a normal .select() with the same client before debugging the channel.
  • Use separate event, schema, table, and filter keys for postgres_changes.
  • Verify the table is in supabase_realtime.
  • Remove channels in useEffect cleanup and include route params in dependencies.
  • Use postgres_changes for database rows, broadcast for app messages, and presence for online state.
  • Test with one manual insert or update before blaming your UI state code.

Further reading: Supabase "__cf_bm" Cookie Rejected for Invalid Domain: Fix.

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

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

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.