Supabase Realtime is a WebSocket layer in every Supabase project. It has three patterns for sending data to clients. Postgres Changes streams row events over logical replication. Broadcast sends short-lived JSON messages between clients without touching the database. Presence syncs shared state, like online users, through CRDT updates.

Key takeaways

  • Postgres Changes streams row events from the WAL. But it checks Row Level Security for each subscriber. This limits how far it scales on its own.
  • Broadcast and Presence skip the database. This gives lower latency and lets more subscribers connect. But they don't enforce RLS by default.
  • Broadcast from Database combines both methods. A trigger sends changes through Broadcast. This avoids per-subscriber RLS checks at scale.
  • Connection, channel, and message-rate limits vary by plan. Check Supabase's pricing page before you estimate concurrent users.
  • Realtime subscriptions must run client-side or in a long-running server process. A serverless function can't hold a WebSocket open.

Updated 15 August 2026: sources added, experience claims checked against our project record, summary added.

Weighing Supabase against other options? We wrote Supabase Alternatives in 2026. Pairing it with Next.js? See Next.js development capabilities.

What Is Supabase Realtime?

Supabase Realtime is a Phoenix (Elixir) WebSocket server. It ships with every project. It has three modes:

  1. Postgres Changes -- Subscribe to INSERT, UPDATE, DELETE, TRUNCATE events on tables
  2. Broadcast -- Send JSON messages between clients through named channels, with no database involved
  3. Presence -- Sync shared state (online users, cursor positions) using CRDTs

You use all three through the same .channel() API in @supabase/supabase-js v2.x. You can mix modes on one channel.

How Does the Architecture Work Under the Hood?

Realtime runs as a separate Elixir service next to your Postgres instance. For Postgres Changes, it taps into PostgreSQL's logical replication. Older setups use wal2json. Newer ones use pgoutput. It reads the write-ahead log and sends changes to subscribers. For Broadcast and Presence, the server just acts as a message broker. It does no database reads or writes.

This split is why Broadcast scales better than Postgres Changes. Postgres Changes adds load to your database through WAL parsing and per-subscriber RLS checks. Broadcast just routes messages in memory on the Realtime server.

Mode 1: Postgres Changes

Subscribe to a table. Get events when rows change.

When to use it

  • Real-time dashboards showing database state
  • Notification feeds
  • Admin panels with light write traffic (well under 100 changes per second)
  • Any scenario where a database write is the source of truth

Working code

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

const channel = supabase
  .channel('orders-changes')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'orders',
      filter: 'status=eq.pending',
    },
    (payload) => {
      console.log('New pending order:', payload.new)
    }
  )
  .subscribe((status) => {
    console.log('Subscription status:', status)
  })

// Cleanup
// await supabase.removeChannel(channel)

The filter supports eq, neq, gt, gte, lt, lte, in. One column per subscription. Need multi-column logic? Filter client-side or use Broadcast from Database (covered below).

Key behaviors

  • payload.new has the full row after the change. payload.old is only filled in if you set REPLICA IDENTITY FULL on the table
  • Without REPLICA IDENTITY FULL, deletes give you an old object with only the primary key
  • Each subscription creates a replication slot listener. This costs real resources at scale

Mode 2: Broadcast

Send messages between clients. No database involved.

When to use it

  • Chat (ephemeral or persisted separately)
  • Cursor positions in collaborative tools
  • Game state updates
  • High-frequency events where durability doesn't matter

Working code

Jack Herrington built a chat demo using Broadcast with React and Next.js. He published it on Stackademic. His pattern stores the channel ref and subscribes inside useEffect. It works well for client components. Here's a version of it:

import { createClient, RealtimeChannel } from '@supabase/supabase-js'
import { useEffect, useRef, useState } from 'react'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

export function useChat(roomId: string) {
  const [messages, setMessages] = useState<string[]>([])
  const channelRef = useRef<RealtimeChannel | null>(null)

  useEffect(() => {
    const channel = supabase.channel(`chat:${roomId}`, {
      config: { broadcast: { self: true } },
    })

    channel
      .on('broadcast', { event: 'message' }, ({ payload }) => {
        setMessages((prev) => [...prev, payload.text])
      })
      .subscribe()

    channelRef.current = channel

    return () => {
      channel.unsubscribe()
      channelRef.current = null
    }
  }, [roomId])

  const send = (text: string) => {
    channelRef.current?.send({
      type: 'broadcast',
      event: 'message',
      payload: { text },
    })
  }

  return { messages, send }
}

Note self: true. By default, Broadcast does not echo messages back to the sender. If your chat UI needs the sender to see their own message in the callback, set this option.

Broadcast does not persist

Client offline when a message is sent? They miss it. If you need history, write to the database first and use Postgres Changes, or write to the database and broadcast at the same time.

Mode 3: Presence

Track shared state across clients using CRDT-based sync. Each client sets its own state. All clients get the full state map.

When to use it

  • "Who's online" badges
  • Avatar stacks showing active collaborators
  • Typing indicators
  • State that should vanish when a user disconnects

Working code

const channel = supabase.channel('document:abc')

channel
  .on('presence', { event: 'sync' }, () => {
    const state = channel.presenceState()
    console.log('Online users:', Object.keys(state).length)
  })
  .on('presence', { event: 'join' }, ({ key, newPresences }) => {
    console.log('Joined:', key, newPresences)
  })
  .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
    console.log('Left:', key, leftPresences)
  })
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await channel.track({
        userId: 'user-123',
        name: 'Alice',
        cursor: { x: 0, y: 0 },
      })
    }
  })

track() sets this client's state. When the client disconnects, or calls untrack(), its presence disappears from all other clients. The sync event fires any time the full state changes: joins, leaves, or updates.

Presence payload size

Keep tracked state under 1KB per entry. If you track cursor positions at 60fps, throttle track() calls to every 50 to 100ms. A 100ms interval is usually smooth enough without flooding the channel.

Broadcast from Database: The Hybrid Approach

Supabase later added Broadcast from Database. It's documented in the Realtime Broadcast guide. Instead of using WAL-based replication like Postgres Changes, you create a database trigger. The trigger calls realtime.send() or realtime.broadcast_changes(). It fires on INSERT, UPDATE, or DELETE. The Realtime server then broadcasts the change through the Broadcast protocol. This approach fits most production cases that need database-driven realtime at scale, because it avoids checking RLS for every subscriber.

Why this matters

Postgres Changes reads the WAL for every subscriber. Say 10,000 clients subscribe to the same table. The Realtime server processes the WAL event once. But it still checks RLS policies for each subscriber. Broadcast from Database skips this step. The trigger runs once, and the message fans out through Broadcast with no per-subscriber RLS check.

Setup

Create the trigger function:

CREATE OR REPLACE FUNCTION your_table_changes()
RETURNS trigger AS $$
BEGIN
  PERFORM realtime.broadcast_changes(
    'topic:' || NEW.id::text,  -- channel topic
    TG_OP,                      -- INSERT, UPDATE, or DELETE
    TG_OP,                      -- event name
    TG_TABLE_NAME,
    CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END,
    CASE WHEN TG_OP = 'UPDATE' THEN OLD ELSE NULL END
  );
  RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

Attach it:

CREATE TRIGGER your_table_realtime
  AFTER INSERT OR UPDATE OR DELETE
  ON public.your_table
  FOR EACH ROW
  EXECUTE FUNCTION your_table_changes();

Client side:

const id = 'some-entity-id'
await supabase.realtime.setAuth() // Required for Realtime Authorization

const channel = supabase
  .channel(`topic:${id}`, {
    config: { private: true },
  })
  .on('broadcast', { event: 'INSERT' }, (payload) => {
    console.log('Inserted:', payload)
  })
  .on('broadcast', { event: 'UPDATE' }, (payload) => {
    console.log('Updated:', payload)
  })
  .on('broadcast', { event: 'DELETE' }, (payload) => {
    console.log('Deleted:', payload)
  })
  .subscribe()

private: true turns on Realtime Authorization. This is your access control layer, separate from RLS on the WAL.

When Should You Use Broadcast Over Postgres Changes?

Broadcast wins at scale. Here's the breakdown:

Factor Postgres Changes Broadcast Broadcast from Database
Source of events WAL replication Client sends Database trigger
Database load Medium to high None Low (trigger only)
RLS enforcement Per-subscriber, per-event None (use Realtime Auth) None (use Realtime Auth)
Subscriber scaling Degrades as RLS checks multiply Scales well Scales well
Latency Higher (WAL and RLS overhead) Lowest Low
Message history No No No
Filter server-side Single column filter No server filter Custom in trigger
Setup complexity One line client code Client code only SQL trigger and client code

A general rule of thumb:

  • Light subscriber counts (dozens to a few hundred per table): Postgres Changes works fine. Setup is simple and RLS applies automatically.
  • Moderate subscriber counts (up to a couple thousand): Test carefully and watch database CPU. Consider moving to Broadcast from Database.
  • Large subscriber counts (several thousand and up): Use Broadcast or Broadcast from Database. Postgres Changes will strain your database at this scale.

For short-lived events that don't come from database writes (cursor moves, typing indicators, game inputs), always use Broadcast. There's no reason to involve the database.

Gotcha: RLS and Realtime

This catches most teams. Row Level Security policies are enforced on Postgres Changes subscriptions. But this check happens at the Realtime server, not in Postgres itself. The Realtime server reads the WAL event, then checks your RLS policies for each subscriber to decide whether to send it.

This means:

  1. RLS policies must use the authenticated role. The Realtime server acts as the subscribing user, using their JWT. Policies that reference auth.uid() work fine. Policies that reference server-side functions or roles will not work.

  2. Complex RLS policies multiply CPU cost. Say your policy joins three tables to check access. That join runs once per subscriber, per event. With 1,000 subscribers and a 5ms policy, that adds up to 5 seconds of CPU time per database change.

  3. Broadcast and Presence do not enforce RLS. By default, anyone with a valid anon key can subscribe to any Broadcast channel. To restrict access, use Realtime Authorization. This means setting private: true and calling supabase.realtime.setAuth(). It was introduced alongside Broadcast from Database.

  4. If you don't enable RLS on a table, Postgres Changes sends events to all subscribers. Sometimes that's fine, like for public dashboards. But it's a security risk if the table holds user-specific data.

How to check your RLS setup

-- Check if RLS is enabled
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';

-- Check existing policies
SELECT * FROM pg_policies
WHERE schemaname = 'public';

If rowsecurity is false for a table you're subscribing to, every subscriber gets every event. No filtering.

Gotcha: Connection and Channel Limits Per Plan

Supabase enforces hard limits. Your connections get rejected if you go over them. Supabase publishes current numbers on its pricing page:

Plan Price Concurrent Connections Channels per Connection Messages per Second
Free $0/mo 200 100 100
Pro $25/mo 500 100 1,000
Team $599/mo 1,000 100 10,000
Enterprise Custom Custom Custom Custom

Check supabase.com/pricing for the latest numbers before you plan capacity.

A "connection" is one WebSocket from a client. A "channel" is a subscription within that connection. One browser tab typically opens one connection and can subscribe to up to 100 channels.

Here's the gotcha. If you expect 1,000 simultaneous users on the Free plan, you're 5 times over the limit. Teams that discover this on launch day end up scrambling to upgrade, instead of planning connection budgets ahead of time.

Practical advice

  • Estimate concurrent users, not total users. A 50,000-user app might have 200 to 500 concurrent users at peak
  • Use one channel per user session where possible. Don't create a new channel for each component. Instead, multiplex events on one channel
  • Unsubscribe when components unmount. Leaked subscriptions count against your limit

Gotcha: Scaling Beyond a Few Thousand Concurrent Users

If your app needs many thousands of concurrent Realtime connections, plan carefully.

Postgres Changes doesn't scale linearly. Each subscriber makes the Realtime server check RLS policies against the WAL event. Say you have 5,000 subscribers on one table with 10 writes per second. That's 50,000 RLS checks per second. Your database will feel it.

Broadcast from Database was built to fix this. You move the filtering logic into the trigger. There, you decide what data to send and to which channel topic. The Realtime server then just fans out messages. Supabase's Realtime documentation says this approach suits tens of thousands of concurrent subscribers.

If you need to go beyond Supabase's managed infrastructure:

  1. Self-host the Realtime server. The supabase/realtime repo is open source (Apache 2.0). Run multiple instances behind a load balancer.
  2. Use a dedicated real-time service like Ably or Pusher for fan-out, with Supabase handling the database. We compared options in our Supabase alternatives guide.
  3. Edge caching with Cloudflare Durable Objects or similar for high-frequency state that doesn't need a central server.

Serverless functions and WebSocket-based Realtime work well together, but they need different infrastructure thinking. A serverless function can't hold a WebSocket connection open because it times out. So Realtime subscriptions must run client-side or in a long-running server process.

Putting It All Together in a Next.js App

Here's a pattern for combining all three modes on one channel, relevant to Next.js development:

// hooks/useCollaborativeDoc.ts
import { createClient } from '@supabase/supabase-js'
import { useEffect, useRef, useState } from 'react'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

interface CursorPosition {
  userId: string
  x: number
  y: number
}

export function useCollaborativeDoc(docId: string, userId: string) {
  const [doc, setDoc] = useState<any>(null)
  const [cursors, setCursors] = useState<Record<string, CursorPosition>>({})
  const [onlineUsers, setOnlineUsers] = useState<string[]>([])
  const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null)

  useEffect(() => {
    const channel = supabase.channel(`doc:${docId}`)

    channel
      // Mode 1: Listen to document changes from the database
      .on(
        'postgres_changes',
        { event: 'UPDATE', schema: 'public', table: 'documents', filter: `id=eq.${docId}` },
        (payload) => setDoc(payload.new)
      )
      // Mode 2: Broadcast cursor positions
      .on('broadcast', { event: 'cursor' }, ({ payload }) => {
        setCursors((prev) => ({ ...prev, [payload.userId]: payload }))
      })
      // Mode 3: Track who's online
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState()
        setOnlineUsers(
          Object.values(state).flat().map((p: any) => p.userId)
        )
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          await channel.track({ userId })
        }
      })

    channelRef.current = channel

    return () => {
      channel.unsubscribe()
    }
  }, [docId, userId])

  const moveCursor = (x: number, y: number) => {
    channelRef.current?.send({
      type: 'broadcast',
      event: 'cursor',
      payload: { userId, x, y },
    })
  }

  return { doc, cursors, onlineUsers, moveCursor }
}

One channel handles document updates (Postgres Changes), cursor sharing (Broadcast), and online status (Presence). Three modes, one WebSocket connection, one channel against your quota.

Important: this must be a Client Component

In the Next.js App Router, this hook must run in a Client Component ('use client'). Server Components can't hold WebSocket connections. If you need to process Realtime events on the server, use a separate long-running process, like a Node.js server or a Supabase Edge Function with Deno. Don't use a Server Component.

FAQ

Can I use Supabase Realtime with the free plan in production?

Yes. But the free plan caps you at 200 concurrent connections and 100 messages per second. This suits a small internal tool or an MVP with under 50 simultaneous users. For anything customer-facing with growth potential, start on the Pro plan at $25 a month for higher limits.

Does Supabase Realtime work with self-hosted Supabase?

Yes. Supabase's Realtime server is open source under Apache 2.0. Self-hosted deployments through Docker Compose include the Realtime container by default. You need to set the REALTIME_* environment variables and turn on logical replication on your Postgres instance before subscriptions will work.

What happens if my client loses connection?

The supabase-js client reconnects on its own using exponential backoff. But anything sent while you're offline is gone. Broadcast messages sent during a disconnect are lost. Postgres Changes events that fired during that window are lost too, since there's no replay. Presence state syncs again once the connection returns.

Can I filter Postgres Changes by multiple columns?

No. The server-side filter on Postgres Changes only supports one column per subscription. For multi-column filtering, subscribe without a filter and filter events in your client callback instead. Or switch to Broadcast from Database, where you can put any logic inside the SQL trigger.

Is message ordering guaranteed?

Ordering is guaranteed within a single channel, but not across channels. Postgres Changes events follow the WAL order for a given table. Broadcast messages stay ordered per sender. So two different senders on the same channel can still interleave in unpredictable ways.

How does Supabase Realtime compare to Firebase Realtime Database?

Firebase Realtime Database is a JSON document store with built-in offline-first sync. Supabase Realtime is a WebSocket layer on top of PostgreSQL. Supabase doesn't include offline support out of the box. So you need to build a local cache layer yourself if your app needs it. We covered this in our alternatives comparison.