Fix: Supabase Realtime Not Working — RLS Filters, Channel Subscribe, Presence, and Broadcast
Part of: React & Frontend Errors
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 pipeline has three stages: Postgres writes WAL entries when rows change, an Elixir-based realtime server tails the WAL via a logical replication slot, and that server forwards filtered events to subscribed clients over Phoenix channels. Each stage has its own failure mode, and because the pipeline is one-directional with no acknowledgements back to the writer, “I wrote the row but the client didn’t see it” is debugged by walking the stages in order.
The four most common failure points. 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. Older Supabase projects ship with the publication restricted; new projects include all tables by default but anyone with admin SQL access can remove tables silently. 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. This is the most common silent failure: the publication is fine, the WebSocket is connected, but RLS gates the event between the realtime server and 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.
There’s also a quieter set of issues around message ordering and delivery semantics. Realtime is best-effort, not guaranteed: brief network blips can drop events, and the client doesn’t know what it missed. This matters when you build features like collaborative editing or chat — you cannot treat realtime as a queue. Always have a way to reconcile against the database (a last_seen_at timestamp plus a backfill query) so that a disconnected client can catch up on what it missed when it reconnects.
How Other Tools Handle This
Realtime sync is a crowded space with very different architectures. The right choice depends on whether your source of truth is a database or the realtime server.
- Supabase Realtime. Postgres logical replication → WebSocket. Strengths: events come directly from your existing Postgres writes; RLS is the same policy you already wrote for REST; presence and broadcast are first-class; free tier is generous. Weaknesses: events scope to “rows in a table I can SELECT,” not arbitrary fan-out; per-connection limits matter at scale; reconnection backfill is your responsibility.
- Pusher. Channels with pub/sub semantics. You publish messages from your backend, subscribers receive them. No database integration — you decide what to publish. Strengths: simple model, mature client libraries in every language, presence channels built in. Weaknesses: you pay for connections and messages; no built-in source of truth, so you reconcile state yourself.
- Ably. Similar pub/sub to Pusher but with richer features: history (rewind a channel up to 24 hours), end-to-end encryption, push notifications. Strengths: enterprise-grade SLAs, deep client SDKs, ordering guarantees on a per-channel basis. Weaknesses: priciest of the bunch; overkill for “list of online users.”
- Convex. Reactive backend where queries are subscriptions. You write a query function; Convex’s engine re-runs it when relevant data changes and pushes new results to subscribed clients. Strengths: no separate realtime layer to wire — every query is realtime by default; offline support; eventually-consistent caching. Weaknesses: Convex is your whole backend, not just realtime; not a drop-in for an existing Postgres app.
- Liveblocks. Specialized for collaborative editing (cursors, multiplayer presence, CRDT-backed shared documents). Strengths: shipping a Figma-style multiplayer feature in days; presence and history built in. Weaknesses: narrower scope — for general pub/sub or DB-driven events, use one of the others.
If your source of truth is already Postgres and you want CDC, Supabase Realtime fits. For arbitrary fan-out from backend to clients, Pusher or Ably. For “backend is the realtime layer,” Convex. For collaborative-editing UIs specifically, Liveblocks. The subscribe/RLS/auth debugging steps below are most acute on Supabase (because RLS adds a layer); pub/sub tools fail more often on “did your backend actually publish?”
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. - Channel name collisions across browser tabs. Two tabs in the same browser share local Supabase state. Using the same channel name in both leads to one tab cleaning up the other’s subscription on
removeChannel. Suffix channel names with a per-tab UUID. - Realtime works in dev but breaks behind a reverse proxy in prod. nginx, Cloudflare, and AWS ALB all need explicit WebSocket upgrade headers. The classic symptom is
subscribe()hanging inTIMED_OUT. Confirmproxy_set_header Upgrade $http_upgradeandConnection "upgrade"on the WebSocket route. - Presence shows ghost users. A client crashed without
untrack(). Supabase eventually expires them (~30s default) but the gap is visible. Lower the expiry viapresence: { key, timeout: 10000 }if you need faster cleanup.
For related Supabase and realtime issues, see Supabase not working, Socket.IO not connecting, Postgres row level security not working, and nginx 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: Next.js 15 cookies() Should Be Awaited — Route Used cookies, Cannot Modify Errors, and Library Mismatch
Fix Next.js 15 async cookies() and headers() errors — 'Route used cookies', 'Cookies can only be modified in a Server Action or Route Handler', codemod misses, library compatibility, and TypeScript type mismatches after upgrade.
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.