Realtime: Postgres Changes Troubleshooting
Last edited: 8/26/2026
A Realtime subscription connects, a row changes in the database, and nothing arrives on the client. No error, no event — silence. This is one of the more common issues developers run into with Supabase Realtime, and the cause is almost always one of a handful of things.
The order below reflects how often each one turns out to be the actual problem. Check RLS early, even if the subscription code looks fine — it's the single biggest source of "silently missing" events, by a wide margin.
For a broader index of Realtime-specific issues, see the Troubleshooting directory.
Step 1: Is the table in the Realtime publication?#
Realtime doesn't watch every table by default. Each one has to be added to a publication called supabase_realtime. If it isn't, Realtime has no visibility into that table at all — no matter how the client subscription is set up.
select * from pg_publication_tables where pubname = 'supabase_realtime';If the table's missing from the results:
alter publication supabase_realtime add table your_table;Or toggle it on from Database → Replication in the dashboard.
This gets overlooked on a table created recently — creating the table and enabling Realtime on it are two separate steps:
alter publication supabase_realtime add table messages;Step 2: Is RLS quietly blocking the row?#
This is the most common cause, and it's the one that wastes the most time, because nothing errors. The event doesn't show up.
Realtime enforces RLS the same way a normal query would — as the subscribing client's role. SUBSCRIBED only means the WebSocket connected. It says nothing about whether that client is allowed to see the data.
Test it directly with either options:
Using the same credentials the client uses, try to select the row that changed:
const { data, error } = await supabase.from('messages').select('*').eq('id', theRowIdThatChanged)console.log({ data, error })Or on the dashboard, open Table Editor, and try to view the row as the subscribing user's role.
Empty data? That's the answer. The policy is blocking this row for this user, and Realtime is doing exactly what it's supposed to.
A fix might look like:
create policy "Users can view messages in their rooms"on messages for selectusing ( exists ( select 1 from room_members where room_members.room_id = messages.room_id and room_members.user_id = auth.uid() ));One trap specific to UPDATE events: RLS generally has to allow both the old and new row state. If a policy only permits status = 'active', and an update flips the status to archived, the event can fail to deliver — the row was visible a second ago, but the new state no longer passes the check.
See Row Level Security for the underlying model, and why a select can return an empty data array, which is directly relevant to the test above.
Step 3: Missing fields in payload.old?#
If events are arriving but payload.old is mostly empty or null, that's a replica identity problem, not a delivery problem.
By default, Postgres only sends the primary key in the "old row" for UPDATE and DELETE. If your code compares old vs. new values, that's not enough:
alter table messages replica identity full;If the table has Row Level Security enabled, replica identity full isn't enough by itself: the old record still contains only the primary key. There's no way around this while RLS is on — don't rely on other payload.old fields for a policy-protected table.
A typical case: a status column moves from pending to completed, and the client checks
if (payload.old.status !== payload.new.status) { notifyUser()}Without replica identity full (or with RLS enabled), payload.old.status is undefined, and undefined !== 'completed' is true — so the check fires notifyUser() on every update, not just the ones where status actually changed.
Step 4: Check the subscription code itself#
Once publication and RLS are ruled out, look at the subscription config. Table name, schema, filter syntax — small mismatches here are common.
- Table name matches exactly, including case
- Schema is correct (
public, unless you're using a custom one) - Filter syntax is right:
room_id=eq.abc123, notroom_id = abc123
const channel = supabase .channel('room-messages') .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}`, }, (payload) => { console.log('Got an update:', payload.new) } ) .subscribe((status) => { console.log('Subscription status:', status) })Log the status callback. Don't assume .subscribe() worked.
SUBSCRIBED— connectedCHANNEL_ERROR— check the error payloadCLOSED— channel got shut downTIMED_OUT— connection issue; see Realtime connections givingTIMED_OUTerrors
A channel stuck at CHANNEL_ERROR is a different problem than one that connects fine but never fires. Figure out which one you're dealing with before going further.
Step 5: Stale or duplicate subscriptions#
This one is almost always a React/Next.js problem.
A component re-renders, roomId changes, and the old channel doesn't get cleaned up. Now you've got two subscriptions running, or one listening against a stale parameter. It doesn't always look broken — sometimes it looks like duplicate events, or events for the wrong room.
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])That cleanup line is the part people skip. Without it, the old channel keeps running against the previous roomId in the background, and nothing in the UI tells you it's happening. See Next.js 13/14 stale data when changing RLS or table data for an adjacent version of the same bug.
Step 6: Writing right after SUBSCRIBED#
There's a confirmed timing gap between the client reporting SUBSCRIBED and the backend's replication listener being ready to stream. A write made in that gap can get missed.
This shows up most in automated tests, where subscribe and write happen back-to-back:
// Risky — writing immediately after subscribingconst channel = supabase.channel('test-channel').on(/* ... */).subscribe()await supabase.from('messages').insert({ text: 'hello' })The reliable fix isn't a fixed delay — it's waiting for the backend to confirm the postgres_changes extension is listening, via the system message it emits:
supabase .channel('room1') .on('system', '*', (payload) => { if (payload.extension === 'postgres_changes' && payload.status === 'ok') { console.log('changes are ready', payload) // safe to write now } }) .on('postgres_changes', { event: '*', schema: '*' }, (payload) => { console.log('Change received!', payload) }) .subscribe()No need to await anything here — react to the message inside the handler (e.g. set a flag) before triggering the write. This system message isn't formally documented yet.
If you can't wire this up right now, a short fixed delay after SUBSCRIBED is a weaker fallback:
channel.subscribe(async (status) => { if (status === 'SUBSCRIBED') { await new Promise((resolve) => setTimeout(resolve, 1000)) await supabase.from('messages').insert({ text: 'hello' }) }})Step 7: Is Realtime enabled?#
Sometimes the answer is straightforward. Check:
- Project Settings → Realtime — enabled at the project level?
- Database → Replication — toggle on for this specific table?
Check this again after a project's been paused and restarted — the replication slot can need to reconnect. If the project's under heavier load than usual, also check the Realtime "Concurrent Peak Connections" quota — hitting that limit can look a lot like a broken subscription.
Step 8: Read the Realtime logs#
Still stuck? Logs → Realtime in the dashboard. Look for connection drops, replication errors, rate limiting.
For more detail than the default logs give you, see Debug Realtime with Logger and Log Levels. If the connection seems to drop intermittently rather than failing outright, check Realtime: Handling Silent Disconnections in Background Applications and Understanding and Monitoring Realtime Heartbeats.
One thing to design around regardless: Realtime doesn't guarantee every message gets delivered. Network blips and reconnects can drop an event here and there. If a missed update matters, treat the Realtime event as a signal to re-fetch state — not as the only source of truth. Then a dropped event is an inconvenience, not a bug.
Still nothing?#
Rule out the network layer:
- Corporate firewall or proxy blocking WebSocket connections
- Outdated
supabase-jsversion — check recent release notes
Hopefully this guide is helpful to you in resolving these types of issues.