DEV Community

Ahmed Hassan Yaseen
Ahmed Hassan Yaseen

Posted on AI-assisted

Supabase Realtime not firing? 7 checks when postgres_changes sends nothing

You subscribe to a table, the status callback says SUBSCRIBED, a row changes in the database, and your app hears nothing. No error in the console, no event, just silence.

Supabase's own troubleshooting guide describes exactly this and says the cause is almost always one of a handful of things. Below are the checks in the order I'd run them, with the SQL and code for each. Everything here is taken from the Supabase Postgres Changes docs, their Postgres Changes troubleshooting guide, and their messages not arriving guide, all read on 7 November 2026.

First, a working baseline

This is the shape of a subscription that should fire for every change on one table:

const channel = supabase
  .channel('todos-changes')
  .on(
    'postgres_changes',
    { event: '*', schema: 'public', table: 'todos' },
    (payload) => console.log('Change received:', payload)
  )
  .subscribe((status) => console.log('Subscription status:', status))
Enter fullscreen mode Exit fullscreen mode

Two details from the docs: the channel name can be any string except realtime, and event is one of INSERT, UPDATE, DELETE or *. Keep this bare version handy. If your real subscription has filters, you'll strip back to this later.

1. Is the table in the supabase_realtime publication?

Realtime doesn't watch every table by default. A table has to be added to a publication called supabase_realtime, and creating a table and enabling Realtime on it are two separate steps. That's easy to miss when a table was created by a migration or by an AI builder.

Check what's in the publication:

select * from pg_publication_tables where pubname = 'supabase_realtime';
Enter fullscreen mode Exit fullscreen mode

If your table isn't listed, add it:

alter publication supabase_realtime add table todos;
Enter fullscreen mode Exit fullscreen mode

You can also toggle it on in the dashboard under Database, then Publications.

2. Is Row Level Security quietly hiding the row?

Supabase calls this the most common cause by a wide margin, and the one that wastes the most time, because nothing errors.

Realtime enforces RLS as the subscribing client's role, the same way a normal query would. SUBSCRIBED only means the WebSocket connected. It says nothing about whether that user is allowed to see the row that changed.

The quickest test is to run a normal select with the same client and the same signed-in user:

const { data, error } = await supabase
  .from('todos')
  .select('*')
  .eq('id', idOfTheRowThatChanged)

console.log({ data, error })
Enter fullscreen mode Exit fullscreen mode

If data is empty, that's your answer. Don't test this in the SQL Editor as postgres: that role bypasses RLS, so the row will always show up there.

The fix is a select policy that matches who should see the row. For a table where each row belongs to one user, it looks like this:

create policy "Users can read their own todos"
on todos for select
to authenticated
using ( (select auth.uid()) = user_id );
Enter fullscreen mode Exit fullscreen mode

Two traps to know about:

  • UPDATE events need both states to pass. Supabase's guide notes that RLS generally has to allow both the old and the new row. If a policy only allows status = 'active' and an update flips the row to archived, the event can fail to arrive.
  • DELETE events are not filtered by RLS at all. The docs explain that Postgres has no way to check access to a row that's already gone. Keep that in mind before you put anything sensitive in a delete payload.

3. Tables outside public need a grant

Postgres Changes works out of the box for tables in the public schema. If your table lives in another schema, the docs say you also have to grant select on it to the role in the user's access token, for example:

grant select on my_schema.todos to authenticated;
Enter fullscreen mode Exit fullscreen mode

There's no dashboard toggle for that grant. Supabase also strongly recommends turning on RLS and writing policies for those tables, because otherwise any role you grant can read the whole table.

4. Check the subscription itself, character by character

Once the publication and RLS are ruled out, look for small mismatches:

  • The table name matches exactly, including case.
  • The schema is right (public unless you use a custom one).
  • The filter uses Realtime's syntax: room_id=eq.abc123, not room_id = abc123.

Filters are column=operator.value expressions that Realtime evaluates on the server, so a filter that doesn't match means the event never leaves the database. Several conditions can be joined with commas, which works as AND; OR isn't supported.

Then log the status callback instead of assuming it worked. SUBSCRIBED means connected, CHANNEL_ERROR means read the error payload, CLOSED means the channel was shut down, and TIMED_OUT points to a connection problem. A channel that errors and a channel that connects but never fires are different problems, so work out which one you have.

If nothing turns up, Supabase's advice is to strip the subscription to the bare minimum: no filter and event: '*'. If that still never fires, it's a publication or RLS problem, not your filter.

5. Stale subscriptions in React and Next.js

If you see duplicate events, or events for the wrong room, a re-rendered component is probably leaving an old channel running. Clean it up when the component unmounts or the parameter changes:

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

  return () => {
    supabase.removeChannel(channel)
  }
}, [roomId])
Enter fullscreen mode Exit fullscreen mode

The removeChannel line in the cleanup is the part that usually goes missing.

6. Writing straight after SUBSCRIBED

This one bites automated tests most. Supabase confirms a short gap between the client reporting SUBSCRIBED and the server being ready to stream changes, so a write made in that gap can be missed. Their guide shows listening for the system message that says the postgres_changes extension is ready, and only then writing. A one-second delay after SUBSCRIBED is the weaker fallback they mention.

7. Events arrive, but payload.old is empty

If events do come through but payload.old only has the primary key, that's replica identity, not delivery. By default Postgres sends only the primary key as the old row for UPDATE and DELETE. To get the previous values:

alter table todos replica identity full;
Enter fullscreen mode Exit fullscreen mode

Two caveats from the docs. On a table with RLS turned on, payload.old still contains only the primary key, even with replica identity full, so don't build logic that compares old and new values on a policy-protected table. And you can only filter DELETE events when the table's replica identity is full.

Still nothing?

Check that Realtime is enabled for the project, read the Realtime logs in the dashboard, and rule out a firewall or proxy that blocks WebSockets. Supabase's guide also lists an outdated supabase-js as a possible cause, so check you're on a recent version.

Finally, design for the odd missed event. Supabase says plainly that Realtime doesn't guarantee every message is delivered, because network blips and reconnects happen. Treat an event as a signal to re-fetch the data, not as the only source of truth.

Most of the silent cases come back to the same two things: the table was never added to the publication, or a policy that looked fine hides the row from the user who's listening. If you want a second pair of eyes on your policies and grants, I wrote up how to test them as a signed-out visitor and as two different users here: Is the Supabase anon key safe to expose?. And if you're about to launch, our free Launch Check looks at headers, exposed keys and more, with no sign-up.

I wrote this with AI assistance and reviewed it myself.

Top comments (0)