Skip to content

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)
})
  1. Check your project isn't paused and Realtime is enabled for it. That toggle ("Enable Realtime service") lives under Realtime → Settings. See Realtime Settings.
  2. 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.
  3. Bad JWTs are a common cause. Not expired, has role and exp claims, and if you're on a third-party auth provider, its JWKS endpoint needs to be reachable. Decode the token (jwt.io, or atob(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.
  4. Reverse proxy or custom SSL termination in front of your app? Make sure it forwards the Upgrade and Connection headers, or the WebSocket handshake never completes in the first place.
  5. Clients need to heartbeat at least every 25 seconds or the connection times out (Reconnection). So reconnect on unexpected CLOSED/TIMED_OUT instead 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 seeing TIMED_OUT specifically, that's more often a Node.js version mismatch with your realtime-js version than an idle timeout. See Realtime connections giving TIMED_OUT errors.
  6. 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 in err. Plan numbers: Realtime Limits. Related: Realtime: "Concurrent Peak Connections" Quota.

Broadcast from the client#

This applies to messages sent with channel.send({ type: 'broadcast', ... }).

  1. 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.
  2. Private channel? You'll need RLS policies on realtime.messages scoped to extension = 'broadcast' for the connecting role: an insert policy to send, and a select policy to receive. Missing the select policy means sends succeed but nothing comes back. Check Realtime → Policies, and see Realtime Authorization: Broadcast for a working example.
  3. 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().

  1. Check your Postgres logs for a WarnSendingBroadcastMessage warning. realtime.messages is 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: WarnSendingBroadcastMessage and Broadcast from Postgres.
  2. Is the trigger even firing? Add a raise notice inside 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.
  3. Check that is_private (the last argument to realtime.send(), or the equivalent flag on realtime.broadcast_changes()) matches the client channel's config.private setting. 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#

  1. Row Level Security gates delivery for INSERT and UPDATE events. postgres_changes only sends rows the subscribing role could select. Run that same select using the subscribing role's credentials, not as postgres in 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. DELETE events 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.
  2. 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.
  3. The table needs to be in the supabase_realtime publication, regardless of schema. That's under Database → Publications. If it's not in public, there's a separate requirement too. Grant select on the table to the role in the subscriber's access token: authenticated for a logged-in user, anon for 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.
  4. 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#

  1. Something in your codebase may have set presence: { enabled: false } on the channel config. Worth grepping for it. Config shape reference: phx_join config.
  2. Private channel? You'll need RLS policies on realtime.messages scoped to extension = 'presence' for the connecting role: a select policy to receive presence updates and an insert policy for channel.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.
  3. channel.track() needs to wait for SUBSCRIBED: every example in the Presence guide gates it this way, and calling it earlier won't reliably register. Log the status callback and confirm track() only fires after SUBSCRIBED shows 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.