Realtime: Messages Not Arriving Troubleshooting
Last edited: 9/21/2026
Your channel subscribes, something changes, and the client never hears about it. No error, silence. This guide is a quick-reference across the main causes: connection problems, Broadcast, Postgres Changes, and Presence.
Start with Connection problems if your channel never subscribes. If it connects fine and the problem is that specific events aren't showing up, skip ahead to whichever feature you're using.
Connection problems#
Look for CHANNEL_ERROR, TIMED_OUT, or CLOSED right after you call .subscribe():
channel.subscribe((status, err) => { console.log(status, err)})- Check your project isn't paused and Realtime is enabled for it. That toggle ("Enable Realtime service") lives under Realtime → Settings. See Realtime Settings.
- On a private channel, you need an RLS policy that allows the join for your role. Check Realtime → Policies on
realtime.messages. See Realtime Authorization for how the policies work. A slow policy can also cause the join to time out instead of failing outright, so check the policy's own performance too, not merely whether it grants access. - Bad JWTs are a common cause. Not expired, has
roleandexpclaims, and if you're on a third-party auth provider, its JWKS endpoint needs to be reachable. Decode the token (jwt.io, oratob(token.split('.')[1])) to check the claims directly. Logging the second argument in.subscribe((status, err) => ...)will show you the actual rejection reason instead of leaving you guessing. More detail: Channel-level system errors. - Reverse proxy or custom SSL termination in front of your app? Make sure it forwards the
UpgradeandConnectionheaders, or the WebSocket handshake never completes in the first place. - Clients need to heartbeat at least every 25 seconds or the connection times out (Reconnection). So reconnect on unexpected
CLOSED/TIMED_OUTinstead of treating either as a hard error: log the status callback so you can tell an expected disconnect apart from a real failure. One caveat: if you're seeingTIMED_OUTspecifically, that's more often a Node.js version mismatch with yourrealtime-jsversion than an idle timeout. See Realtime connections givingTIMED_OUTerrors. - You might be hitting a connection or join-rate limit for your plan (
too_many_connections,too_many_joins). Check the project's usage page in the dashboard, or look for the error code directly inerr. Plan numbers: Realtime Limits. Related: Realtime: "Concurrent Peak Connections" Quota.
Broadcast from the client#
This applies to messages sent with channel.send({ type: 'broadcast', ... }).
- Topic name has to match exactly on the sender and every listener (
supabase.channel('room-1')). A typo, a casing difference, or an ID built differently on each side won't throw any error, it'll fail quietly. Log the topic string on both sides and diff them if you're stuck, or enable client and server logging to see what the Realtime server actually received: Debug Realtime with Logger and Log Levels. - Private channel? You'll need RLS policies on
realtime.messagesscoped toextension = 'broadcast'for the connecting role: aninsertpolicy to send, and aselectpolicy to receive. Missing theselectpolicy means sends succeed but nothing comes back. Check Realtime → Policies, and see Realtime Authorization: Broadcast for a working example. - Same rate limits apply here as connections (message-per-second, channel limits). Same usage page, same Realtime Limits reference.
Broadcast from the database#
This applies to realtime.send() and realtime.broadcast_changes().
- Check your Postgres logs for a
WarnSendingBroadcastMessagewarning.realtime.messagesis partitioned by day, and that partition only gets created once a client has connected over WebSocket. Broadcast before that happens, or after your clients stop connecting for a while, and the insert fails with this warning. Connecting a client first usually clears it up. Background: Realtime:WarnSendingBroadcastMessageand Broadcast from Postgres. - Is the trigger even firing? Add a
raise noticeinside it and check Logs → Postgres Logs. Nothing logged means the trigger isn't wired up right: wrong table, wrong event, or it was never created. That's a problem upstream of Realtime entirely. - Check that
is_private(the last argument torealtime.send(), or the equivalent flag onrealtime.broadcast_changes()) matches the client channel'sconfig.privatesetting. It's the public/private flag on the topic itself, separate from the RLS checks above. A mismatch there means the client never receives the message even when the trigger fires and the topic name is right. See Broadcast.
postgres_changes#
- Row Level Security gates delivery for
INSERTandUPDATEevents.postgres_changesonly sends rows the subscribing role couldselect. Run that same select using the subscribing role's credentials, not aspostgresin the SQL Editor (which bypasses RLS by default): an empty result means RLS is the culprit. Add a permissive select policy for testing, then tighten it back up once you've confirmed that's the issue.DELETEevents aren't RLS-filtered at all, since Postgres has no way to check access on a row that's already gone. Related reading: Interaction with Postgres Changes. - Double-check your filter arguments against the actual row that changed:
schema,table,event(INSERT/UPDATE/DELETE/*), and any.eq()/.in()filter. See Available filters for the full list. - The table needs to be in the
supabase_realtimepublication, regardless of schema. That's under Database → Publications. If it's not inpublic, there's a separate requirement too. Grantselecton the table to the role in the subscriber's access token:authenticatedfor a logged-in user,anonfor an anonymous client, or another role if you're on a custom JWT (e.g.grant select on "your_schema"."your_table" to authenticated;). There's no dashboard toggle for that part. Details: Private schemas. - When none of the above turns anything up, strip the subscription down to bare minimum: no filter,
event: '*'. If that still never fires, you're looking at a publication or RLS problem, not a filter syntax issue.
Once you've ruled out the basics, Realtime: Postgres Changes Troubleshooting goes deeper into this exact scenario.
Presence#
- Something in your codebase may have set
presence: { enabled: false }on the channel config. Worth grepping for it. Config shape reference: phx_join config. - Private channel? You'll need RLS policies on
realtime.messagesscoped toextension = 'presence'for the connecting role: aselectpolicy to receive presence updates and aninsertpolicy forchannel.track()to publish your own. Missing either can silently drop presence even though the channel subscription itself succeeds. See Realtime Authorization: Presence for a working example. channel.track()needs to wait forSUBSCRIBED: every example in the Presence guide gates it this way, and calling it earlier won't reliably register. Log the status callback and confirmtrack()only fires afterSUBSCRIBEDshows up. See Sending state for the canonical pattern.
Still stuck?#
The Realtime troubleshooting entries cover related issues too: TIMED_OUT connections, too many channels, rate limits, logger debugging.