Realtime: postgres_changes is easy; Broadcast from triggers scales

postgres_changes replays changes through RLS per subscriber; DELETE events ignore filters. broadcast_changes() from a trigger with private channels scales.

Realtime

· Chapter

36

·

3

min read

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 reset turns Realtime off on every table; put the publication statement in a migration.
  • A postgres_changes listener without a filter receives every change from every published table.
  • Watch the subscribe status and resubscribe on anything but SUBSCRIBED; 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