Skip to content

Realtime

Realtime pushes three kinds of events over one WebSocket per client:

  • Database changes (postgres_changes): inserts, updates and deletes, filtered by your RLS policies.
  • Broadcast: low-latency messages between clients, or from SQL after commit.
  • Presence: who is online in a channel.

Realtime streams only the tables you list. Use the dashboard (Realtime → Configuration) or the Management API:

PATCH /v1/projects/{ref}/config/realtime
{ "enabled": true, "tables": ["public.todos", "public.messages"], "public_channels": ["lobby", "public:*"] }

public_channels lists channels a client may join with private: false (an exact name or a prefix ending in *). Changes apply within about a second.

const ch = base
.channel("todos-feed")
.on("postgres_changes",
{ event: "INSERT", schema: "public", table: "todos", filter: `user_id=eq.${userId}` },
(p) => {
p.eventType // "INSERT" | "UPDATE" | "DELETE"
p.new // new row
p.old // DELETE: primary key only
})
.on("system", {}, (p) => { if (p.code === "resync") refetch() })
.subscribe((status, err) => {
// "SUBSCRIBED" | "CHANNEL_ERROR" (err.code) | "TIMED_OUT" | "CLOSED"
})
await base.removeChannel(ch)

A join is accepted when the table is enabled for realtime, the role has SELECT on it, a table with RLS has a single-column primary key and the filter column exists. Filters take one condition: eq, neq, lt, lte, gt, gte or in (status=in.(new,open)).

Every event is checked against your SELECT policy as the subscriber. Practical consequences:

  • Use a filter such as user_id=eq.<uid> whenever you can. At most 200 distinct groups may subscribe to a table without a filter; the next join fails with filter_required.
  • A DELETE event carries only the primary key and goes to every unfiltered subscriber with SELECT on the table. If key values are sensitive, filter or use broadcast.
  • Realtime is not a durable queue. After a reconnect (or a truncate or oversized transaction) you get a system event with code: "resync": refetch your data.
const room = base.channel(`room:${roomId}`, { config: { broadcast: { self: false, ack: true } } })
.on("broadcast", { event: "typing" }, ({ payload }) => showTyping(payload))
.subscribe()
await room.send({ type: "broadcast", event: "typing", payload: { userId } }) // "ok" | "error" | "timed out"

Channels are private by default. A join is authorized by policies on realtime.channel_access, with action of join (receive), broadcast (send) or presence (track):

create policy room_members on realtime.channel_access for select to authenticated
using (
channel like 'room:%'
and exists (select 1 from public.room_members m
where m.room_id = split_part(channel, ':', 2)::uuid
and m.user_id = (select auth.uid()))
);

A channel that only uses postgres_changes needs no channel policy. base.channel("lobby", { private: false }) works only for names listed in public_channels.

const ch = base.channel(`room:${roomId}`, { config: { presence: { key: userId } } })
.on("presence", { event: "sync" }, () => render(ch.presenceState()))
.on("presence", { event: "join" }, ({ key, newPresences }) => {})
.on("presence", { event: "leave" }, ({ key, leftPresences }) => {})
.subscribe()
await ch.track({ online: true, name }) // re-tracked after reconnects

Presence state is limited to 4 KiB per client.

Triggers can publish events that are delivered only after the transaction commits:

create function public.notify_order_status() returns trigger
language plpgsql security definer set search_path = '' as $$
begin
perform realtime.send(
channel => 'order:' || new.id, event => 'status_changed',
payload => jsonb_build_object('status', new.status), private => true);
return new;
end $$;
create trigger order_status_broadcast after update of status on public.orders
for each row execute function public.notify_order_status();

realtime.send is not executable by anon or authenticated: call it from SECURITY DEFINER code. Payloads are limited to 64 KiB.

The SDK keeps the access token out of the URL, refreshes it on the open socket, sends a heartbeat every 25 s and reconnects with exponential backoff (1 s up to 30 s). Channels become SUBSCRIBED again after each reconnect.

Default per-project limits: 500 connections, 20 connections per IP, 100 joins per connection, 20 client messages per second per connection, 64 KiB broadcast payload, 4 KiB presence state. Hitting a limit returns an error frame (rate_limited, too_many_joins, payload_too_large) and repeated violations close the socket. Connection and message allowances also depend on your plan.