The answer. Two ways to push database changes to browsers share one WebSocket. postgres_changes is the quick one: enable the table in the supabase_realtime publication, subscribe with a filter, and each subscriber receives INSERT, UPDATE and DELETE payloads that Realtime checks against RLS as that user. It is simple and it has two edges: every subscriber costs a policy evaluation per change, and DELETE events cannot be filtered or RLS-checked, so anyone subscribed to the table learns the primary keys of deleted rows (never enable full old records). Broadcast from the database is the version built for scale: a trigger calls realtime.broadcast_changes() with a topic you choose, Realtime fans the message out once per topic, private channels require an RLS policy on realtime.messages, and messages are kept for three days so a reconnecting client can replay what it missed.
The pattern.
create or replace function public.comments_broadcast() returns trigger security definer set search_path = '' as $$
begin
perform realtime.broadcast_changes('ticket:' || coalesce(new.ticket, old.ticket)::text, tg_op, tg_op, tg_table_name, tg_table_schema, new, old);
return null;
end $$ language plpgsql;
create trigger comments_broadcast after insert or update or delete on public.comments for each row execute function public.comments_broadcast();
create policy "members receive" on realtime.messages for select to authenticated
using (rls_helpers.has_tenant_access(split_part(realtime.topic(), ':', 1))); -- topic carries the tenant
const channel = supabase.channel(`${tenant}:ticket:${id}`, { config: { private: true, broadcast: { replay: { since: lastSeenMs, limit: 50 } } } })
.on("broadcast", { event: "INSERT" }, ({ payload }) => add(payload.record))
.on("broadcast", { event: "DELETE" }, ({ payload }) => remove(payload.old_record.id))
.subscribe((status) => { if (status !== "SUBSCRIBED") console.warn(status); });
Watch out.
supabase db resetturns Realtime off on every table; put the publication statement in a migration.- A
postgres_changeslistener without a filter receives every change from every published table. - Watch the
subscribestatus and resubscribe on anything butSUBSCRIBED; a dropped connection is silent otherwise.
Related: set-created-by-in-a-before-insert-trigger-never-from-the-client · a-policy-that-reads-another-table-runs-that-tables-policies · websockets-in-a-durable-object-with-hibernation