Swarmz

Realtime

Postgres CDC over WebSocket — subscribe to row changes, presence, and broadcast

Realtime streams Postgres change-data-capture (CDC) over a WebSocket. Subscribe to INSERT, UPDATE, and DELETE events on any table and your client gets the row the moment it changes — no polling, no refresh, no setInterval.

It also gives you two channel-level primitives that don't touch the database: presence (who's online) and broadcast (send arbitrary messages between connected clients).

Why use it

  • Chat apps — new messages appear instantly across all open tabs.
  • Live dashboards — KPIs, order feeds, and alerts update as events land.
  • Collaboration cursors — render every connected user's cursor in a shared canvas.
  • Presence indicators — green dots, "X is typing…", who's currently in a doc.
  • Live counters — vote tallies, view counts, bid prices.

Subscribing to row changes

A subscription is a channel with a postgres_changes listener. Pass a callback and you'll receive every matching event.

import { createClient } from "@supabase/supabase-js";

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

const channel = supabase
  .channel("messages-feed")
  .on(
    "postgres_changes",
    {
      event: "INSERT",
      schema: "public",
      table: "messages",
    },
    (payload) => {
      console.log("new message:", payload.new);
    },
  )
  .subscribe();

Insert a row from anywhere — the agent in chat, the SQL editor, another browser tab — and the callback fires within a few hundred milliseconds.

await supabase
  .from("messages")
  .insert({ room_id: "general", body: "hello" });
// → callback runs on every connected client

Always tear the channel down when the component unmounts:

useEffect(() => {
  const channel = supabase.channel("messages-feed").on(...).subscribe();
  return () => { supabase.removeChannel(channel); };
}, []);

Listen to multiple events on the same channel by passing event: "*", or by chaining additional .on() calls.

Filters

Realtime supports server-side filtering so you only receive the rows you care about. Filtering on the server is far more efficient than streaming everything to the client and filtering in JavaScript.

supabase
  .channel("room-1")
  .on(
    "postgres_changes",
    {
      event: "INSERT",
      schema: "public",
      table: "messages",
      filter: "room_id=eq.room-1",
    },
    (payload) => render(payload.new),
  )
  .subscribe();

Operators: eq, neq, gt, gte, lt, lte, in. Compose them on any column. For complex predicates, gate access with RLS policies instead — Realtime obeys them.

Presence

Presence tracks which users are currently subscribed to a channel. Each client publishes its own state; everyone in the channel sees the synced view.

const channel = supabase.channel("room-1", {
  config: { presence: { key: userId } },
});

channel
  .on("presence", { event: "sync" }, () => {
    const state = channel.presenceState();
    console.log("online users:", Object.keys(state));
  })
  .subscribe(async (status) => {
    if (status === "SUBSCRIBED") {
      await channel.track({
        user_id: userId,
        name: "Ada",
        online_at: new Date().toISOString(),
      });
    }
  });

When a client disconnects (closes the tab, loses network), its presence is automatically removed and the sync event fires for everyone else. Use it for active-user lists, "X is typing…" indicators, and seat assignment in collaborative tools.

Broadcast

Broadcast sends ephemeral messages between connected clients without writing to Postgres. Useful for high-frequency events that don't need persistence — cursor positions, live drawing strokes, ephemeral notifications.

const channel = supabase.channel("room-1");

channel
  .on("broadcast", { event: "cursor" }, ({ payload }) => {
    drawCursor(payload.userId, payload.x, payload.y);
  })
  .subscribe();

// Send from any connected client
channel.send({
  type: "broadcast",
  event: "cursor",
  payload: { userId, x: 120, y: 240 },
});

Broadcast messages are not stored anywhere — if a client isn't connected when the message is sent, it doesn't see it. For events that need durability, write to Postgres and use a row-change subscription instead.

Authentication

Realtime channels obey your row-level security policies. By default the WebSocket connects with the anon key, which means any RLS rule that requires auth.uid() will block events from reaching the client.

Pass the user's JWT after they sign in:

const { data: { session } } = await supabase.auth.getSession();
supabase.realtime.setAuth(session?.access_token ?? null);

Now the user only receives change events for rows their RLS policy allows them to read. Same rules, same enforcement, same database — Realtime just streams the deltas.

If you're seeing zero events even though rows are changing, the most common cause is RLS blocking them. Check your policy on the table or temporarily test with the service role key (server-side only).

Limits

Realtime has two billable dimensions: concurrent connections and messages per month. Both scale with your plan.

A "connection" is one open WebSocket — typically one browser tab. A "message" is any event delivered to a client (one row change broadcast to 50 subscribers counts as 50).

Pricing and per-plan limits are discussed during onboarding — contact us for details on what fits your workload.

Cursor-tracking and high-frequency broadcast burn through message quota fast. Throttle to ~30Hz client-side (requestAnimationFrame / debounce) and only send when the value actually changes.

For more detail on table-level setup, see Database. For the underlying API contract, see the API reference.

On this page