Supabase Realtime Not Receiving Events: Complete Fix
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.

2026-06-17
8 min read
Supabase Realtime Not Receiving Events: Complete Fix

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 gotchas, 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.

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 custom JWT or restored sessions, set the Realtime auth token after auth changes:

ts
supabase.auth.onAuthStateChange((_event, session) => {
  if (session?.access_token) {
    supabase.realtime.setAuth(session.access_token);
  }
});

This pattern comes straight from real reports where direct queries worked but Realtime did not receive RLS-protected changes after a stored session was loaded.

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 and receives an app event:

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

If you call send() and expect a database row to appear, you are using broadcast. If you insert a row and expect broadcast to fire, you are listening to the wrong event type.

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.

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.

Written by

Mahdi Br
Mahdi Br

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 — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.