Fix: Supabase Realtime Not Working — RLS Filters, Channel Subscribe, Presence, and Broadcast
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_realtimepublication 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— equalsneq— not equalsgt,gte,lt,lte— comparisonsin—user_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 afterSUBSCRIBED.presenceevent 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 tokenOr 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_realtimepublication, 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
newis empty. Set the table’sREPLICA IDENTITYto 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. SameREPLICA IDENTITYissue —FULLto 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: PGlite Not Working — IndexedDB Persistence, Worker Setup, Extensions, and Live Queries
How to fix PGlite errors — async init not awaited, IndexedDB persistence lost on reload, Web Worker isolation, pgvector and other extensions, live queries with @electric-sql/pglite-react, and migration patterns.
Fix: PGMQ Not Working — Extension Install, Visibility Timeout, Long Polling, and Archive vs Delete
How to fix PGMQ Postgres message queue errors — extension not installed, queue creation, send/read/delete/archive, visibility timeout (vt), long polling, partitioned queues, and Python/Node client setup.
Fix: AWS RDS Proxy Not Working — Endpoint, IAM Auth, Connection Pinning, and Lambda VPC
How to fix AWS RDS Proxy errors — IAM authentication token mismatch, connection pinning blocking reuse, Lambda VPC routing, Secrets Manager rotation, max_connections, read/write splitter, and TLS requirement.
Fix: Cloudflare Durable Objects Not Working — ID Strategy, Storage API, WebSocket Hibernation, Alarms
How to fix Cloudflare Durable Objects errors — idFromName vs newUniqueId, Storage transactions, blockConcurrencyWhile, WebSocket Hibernation API, alarms, migrations, and class binding setup.