Skip to content

Fix: Supabase Realtime Not Working — RLS Filters, Channel Subscribe, Presence, and Broadcast

FixDevs ·

Quick Answer

How to fix Supabase Realtime errors — postgres_changes subscription not firing, RLS blocking events, channel.subscribe callback timing, presence diff payloads, broadcast vs database events, auth refresh, and reconnection.

The Error

You subscribe to a Postgres table and nothing fires when rows change:

supabase
  .channel("orders-changes")
  .on(
    "postgres_changes",
    { event: "*", schema: "public", table: "orders" },
    (payload) => console.log(payload),
  )
  .subscribe();
// INSERT into orders runs, but callback never fires.

Or subscribe itself never resolves:

const channel = supabase.channel("test");
channel.subscribe((status) => {
  console.log("status:", status);  // Never logs "SUBSCRIBED".
});

Or presence diffs come back empty:

channel.on("presence", { event: "sync" }, () => {
  const state = channel.presenceState();
  console.log(state);  // {} — even though other clients are joined.
});

Or the user logs in, but the realtime subscription is still anonymous:

await supabase.auth.signInWithPassword({ email, password });
// Existing subscription still sees the anon role's data.

Why This Happens

Supabase Realtime piggybacks on Postgres logical replication and ships events over WebSocket to clients. The pieces that commonly break:

  • Replication isn’t enabled per-table. Even if you write the SQL trigger correctly, a table not in the supabase_realtime publication won’t emit events.
  • RLS applies to realtime events. The realtime server runs as the authenticated user (via JWT). Without policies that match the realtime context, the event is dropped silently — no error to the client.
  • channel.subscribe() is asynchronous. The status callback is the contract. Without checking the status, you don’t know whether your subscription is live.
  • Auth refresh doesn’t propagate automatically. When the user logs in or refreshes their token, existing channels still use the old JWT until you re-authenticate the realtime client.

Fix 1: Add the Table to the Realtime Publication

By default, Supabase ships a publication called supabase_realtime that includes all tables. But projects upgraded from older Supabase versions, or projects where someone restricted the publication, need explicit add:

-- In SQL Editor:
ALTER PUBLICATION supabase_realtime ADD TABLE orders;
ALTER PUBLICATION supabase_realtime ADD TABLE orders, users, posts;

-- Verify:
SELECT * FROM pg_publication_tables WHERE pubname = 'supabase_realtime';

To enable per-event types (insert/update/delete):

ALTER PUBLICATION supabase_realtime SET (publish = 'insert,update,delete');

For a table you don’t want to leak, exclude it explicitly — never include sensitive tables (auth tokens, payment cards) in the publication.

Pro Tip: Use the Supabase dashboard’s Database → Replication page for a UI version of the same. Less error-prone for one-off changes.

Fix 2: Write RLS Policies That Match Realtime

Realtime uses the same RLS engine as REST. Your select policy decides what events get delivered:

-- Without this, realtime subscribers see nothing:
CREATE POLICY "Users see their own orders" ON orders
  FOR SELECT USING (auth.uid() = user_id);

For tables that should be globally readable in realtime:

CREATE POLICY "Everyone can read orders" ON orders
  FOR SELECT USING (true);

Common Mistake: Defining a INSERT policy and expecting realtime inserts to fire for the user. Realtime delivers what the user can SELECT. Without a SELECT policy that matches, the user can insert rows but won’t see them via realtime.

To debug, run the same query Realtime would as the user:

SET ROLE authenticated;
SET request.jwt.claim.sub TO 'user-uuid-here';
SELECT * FROM orders WHERE id = NEW_ROW_ID;
RESET ROLE;

If this returns no rows for the user’s row, RLS is blocking it.

Fix 3: Always Check the subscribe Status

subscribe is async — the channel isn’t ready until the callback fires with SUBSCRIBED:

const channel = supabase
  .channel("orders-changes")
  .on(
    "postgres_changes",
    { event: "*", schema: "public", table: "orders" },
    (payload) => handle(payload),
  )
  .subscribe((status, err) => {
    if (status === "SUBSCRIBED") {
      console.log("ready");
    } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
      console.error("subscribe failed:", err);
    } else if (status === "CLOSED") {
      console.log("channel closed");
    }
  });

The status values:

  • SUBSCRIBED — channel is live, events will flow.
  • CHANNEL_ERROR — error joining (usually RLS or publication issue).
  • TIMED_OUT — server didn’t respond. Network or server problem.
  • CLOSED — explicitly unsubscribed or the connection dropped.

For React components, set up and tear down in useEffect:

useEffect(() => {
  const channel = supabase
    .channel("orders-changes")
    .on(
      "postgres_changes",
      { event: "*", schema: "public", table: "orders" },
      (payload) => setOrders((prev) => updateWithPayload(prev, payload)),
    )
    .subscribe();

  return () => {
    supabase.removeChannel(channel);
  };
}, []);

The cleanup is critical. Without it, every re-render adds a new channel and you’ll hit Supabase’s per-client channel limit.

Fix 4: Filter Events Efficiently

For high-volume tables, filter at the subscription rather than the client:

supabase
  .channel("my-orders")
  .on(
    "postgres_changes",
    {
      event: "*",
      schema: "public",
      table: "orders",
      filter: `user_id=eq.${userId}`,
    },
    (payload) => handle(payload),
  )
  .subscribe();

filter syntax mirrors PostgREST: column=op.value. Supported ops:

  • eq — equals
  • neq — not equals
  • gt, gte, lt, lte — comparisons
  • inuser_id=in.(1,2,3)

Note: RLS still applies. If a row matches the filter but RLS blocks the user from reading it, the user doesn’t see the event. The filter narrows what reaches the user; RLS gates whether they could read it in the first place.

Fix 5: Presence — Track and Sync

Presence is for “who’s online” lists. Set up:

const channel = supabase.channel("room-1", {
  config: { presence: { key: userId } },
});

channel
  .on("presence", { event: "sync" }, () => {
    const state = channel.presenceState();
    console.log("online:", Object.keys(state));
  })
  .on("presence", { event: "join" }, ({ key, newPresences }) => {
    console.log("joined:", key, newPresences);
  })
  .on("presence", { event: "leave" }, ({ key, leftPresences }) => {
    console.log("left:", key, leftPresences);
  })
  .subscribe(async (status) => {
    if (status === "SUBSCRIBED") {
      await channel.track({ name: userName, online_at: new Date().toISOString() });
    }
  });

Two parts:

  • track() — announce your presence. Call after SUBSCRIBED.
  • presence event handlers — react to others.

Common Mistake: Calling track() before SUBSCRIBED. The track call silently fails or queues without delivery. Always wait for the subscribe status callback.

For untrack on cleanup:

await channel.untrack();
supabase.removeChannel(channel);

Fix 6: Broadcast for Lightweight Messages

For ephemeral messages that don’t need to be in Postgres (typing indicators, cursor positions, game state):

const channel = supabase.channel("game-room");

channel
  .on("broadcast", { event: "move" }, (payload) => {
    console.log("opponent moved:", payload);
  })
  .subscribe(async (status) => {
    if (status === "SUBSCRIBED") {
      await channel.send({
        type: "broadcast",
        event: "move",
        payload: { from: "e2", to: "e4" },
      });
    }
  });

Broadcast messages never touch the database — they go straight through the realtime server to other subscribers on the same channel. Lightweight, low-latency, no Postgres load.

For self-receipt (the sender also gets their own broadcasts):

const channel = supabase.channel("game-room", {
  config: { broadcast: { self: true, ack: true } },
});

ack: true makes send await server acknowledgment — slower, but you know the broadcast was received by the server.

Fix 7: Refresh Auth on Login

Existing channels don’t auto-pick-up new auth state:

// User logs in:
await supabase.auth.signInWithPassword({ email, password });

// Existing channels still use the anon JWT. Refresh:
await supabase.realtime.setAuth();  // Reads the latest session token

Or wire it up automatically:

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

Without this, your channels run as the anonymous user and RLS will (rightly) drop events meant for authenticated users.

Pro Tip: Place this listener at the root of your app once, not inside individual components. Otherwise multiple listeners fight over auth state.

Fix 8: Handle Reconnections

The client auto-reconnects on transient network failures. After reconnect, channels re-subscribe automatically — but events that fired during the disconnect are lost (realtime is not a queue).

For “don’t miss any change,” combine realtime with a poll-on-reconnect:

useEffect(() => {
  let lastSeen = new Date();

  const channel = supabase
    .channel("orders")
    .on(
      "postgres_changes",
      { event: "*", schema: "public", table: "orders" },
      (payload) => {
        lastSeen = new Date();
        applyChange(payload);
      },
    )
    .on("system", { event: "*" }, ({ extension, status }) => {
      if (extension === "postgres_changes" && status === "RECONNECTED") {
        // Backfill changes since lastSeen.
        fetchOrdersSince(lastSeen);
      }
    })
    .subscribe();

  return () => supabase.removeChannel(channel);
}, []);

For high-stakes data (financial transactions, etc.), realtime is best for notification, not source-of-truth — always reconcile against the database.

Still Not Working?

A few less-obvious failures:

  • Subscribed status fires but events still don’t arrive. Check the table is in supabase_realtime publication, and the SELECT RLS policy returns true for the user.
  • Subscribe times out with no error. Outbound WebSocket blocked (corporate firewall). Test with wscat -c wss://YOUR-PROJECT.supabase.co/realtime/v1/websocket?apikey=....
  • “channel subscription error” with realtime version mismatch. Update @supabase/supabase-js — the realtime client and server have a handshake, mismatched versions sometimes break.
  • Events fire twice per change. Two channels are subscribed (likely missing cleanup in a React useEffect, or hot reload leaked one). Verify with supabase.getChannels().
  • Updates fire but new is empty. Set the table’s REPLICA IDENTITY to FULL: ALTER TABLE orders REPLICA IDENTITY FULL. Without it, Postgres only sends primary key on update events.
  • Free tier limits hit. Supabase has per-project realtime connection caps. For demos with many concurrent users, upgrade or share a single channel across UI components.
  • event: "DELETE" returns no record body. Same REPLICA IDENTITY issue — FULL to get the deleted row’s columns.
  • WebSocket auth fails after token expiry. Tokens are valid for an hour by default. The client auto-refreshes via auth.startAutoRefresh() — make sure you haven’t disabled it.

For related Supabase and realtime issues, see Supabase not working, Socket.IO not connecting, Postgres row level security not working, and WebSocket proxy not working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles