Acumatica · Supabase

Supabase Realtime Patterns

How Supabase Realtime actually works on top of Postgres logical replication, and how to use channels, postgres_changes, presence, and broadcast without overloading a single connection or database.

John Kihiu12 min read

Supabase Realtime is not a generic pub/sub bolted onto Postgres — it's built on Postgres's own logical replication stream (via wal2json) plus a separate Elixir/Phoenix layer for presence and broadcast. That distinction matters because the three features people lump together as "Supabase Realtime" — postgres_changes, presence, and broadcast — have very different scaling characteristics. I've used all three in production apps, and the mistake I see most often is treating postgres_changes like a free, infinitely scalable event bus, when it's really tapping the same replication slot every subscribed client shares load against.

postgres_changes is logical replication, not magic

When you subscribe to postgres_changes, Supabase's Realtime server reads Postgres's write-ahead log through a replication slot and forwards matching row changes to connected clients over WebSockets. That means every insert, update, or delete on a watched table gets picked up regardless of which client made the change — no need to manually re-broadcast after every write, but also no way to filter server-side beyond table, schema, and a single equality/inequality condition.

JAVASCRIPT · SUBSCRIBE TO POSTGRES CHANGES
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

const channel = supabase
  .channel('room-messages')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
      filter: `room_id=eq.${roomId}`,
    },
    (payload) => {
      appendMessage(payload.new);
    }
  )
  .subscribe((status) => {
    if (status === 'SUBSCRIBED') console.log('listening for new messages');
  });

// Always clean up — an unclosed channel keeps the replication
// subscription and the WebSocket alive after the component unmounts.
// channel.unsubscribe();
Row Level Security applies to realtime too

postgres_changes payloads are filtered through your table's RLS policies before being sent to a client. If a client isn't receiving events you expect, check RLS first — it's a far more common cause than a broken filter string.

Presence: who else is here right now

Presence tracks ephemeral client state — who's currently connected to a channel — and syncs it across every other client on that channel. It doesn't touch Postgres at all; it lives entirely in the Realtime server's in-memory state, which is why it's cheap to update dozens of times a second for things like cursor position or "user is typing."

JAVASCRIPT · PRESENCE TRACKING
const room = supabase.channel('room-1', {
  config: { presence: { key: userId } },
});

room
  .on('presence', { event: 'sync' }, () => {
    const state = room.presenceState();
    renderOnlineUsers(Object.keys(state));
  })
  .on('presence', { event: 'join' }, ({ newPresences }) => {
    console.log('joined:', newPresences);
  })
  .on('presence', { event: 'leave' }, ({ leftPresences }) => {
    console.log('left:', leftPresences);
  })
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await room.track({ user_id: userId, online_at: new Date().toISOString() });
    }
  });

Broadcast for ephemeral messages that skip the database

Broadcast sends a message directly from one client to others on the same channel without writing anything to Postgres first. Use it for things that don't need to be durable — a "user is drawing" cursor update in a collaborative canvas, a live typing indicator — where the round trip through the database would only add latency for no benefit. If the data needs to survive a page reload, it belongs in a table with postgres_changes, not broadcast.

One Realtime connection per tab, not per subscription

A common performance mistake: creating a separate channel (and therefore a separate WebSocket subscription overhead) for every UI component that needs realtime data, instead of sharing one channel per logical resource across the page. Each .channel() call establishes its own subscription bookkeeping on the server side. Consolidate related subscriptions — messages and typing indicators for the same room, for instance — onto a single channel with multiple .on() handlers rather than spinning up three separate channels.

Replication slots are a shared, finite resource

On the free and smaller paid tiers, Supabase caps concurrent Realtime connections and message throughput. If you're building something with thousands of concurrent users watching the same rows, load-test the Realtime quota specifically — it's a separate limit from your database connection pool.

Wrapping up

The useful mental model for Supabase Realtime is three distinct tools sharing one client API: postgres_changes for durable state that multiple clients need to stay in sync with, presence for ephemeral "who's here" state, and broadcast for ephemeral messages that never touch a table. Picking the wrong one for the job is the most common source of both bugs (data that should have persisted didn't) and performance problems (using postgres_changes for a data stream broadcast would have handled more cheaply). Match the feature to the durability the data actually needs.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.