# Supabase Realtime implementation guide: choosing between Broadcast, Presence, and Postgres Changes — and designing realtime that survives production

> An implementation guide that starts from 'which of the three features do you use, and when'. Covers Broadcast's three send paths (WebSocket, httpSend, and realtime.broadcast_changes from a database trigger), why Presence must not be used for high-frequency updates, the mechanism that caps Postgres Changes at roughly 3,000 subscribers, closing the gap after a reconnect and applying events idempotently, plus per-plan quotas and cost — all with type-safe, official-compliant code.

- Published: 2026-08-16
- Author: 友田 陽大
- Tags: Supabase, リアルタイム, TypeScript, Next.js, PostgreSQL, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/supabase-realtime-broadcast-presence-postgres-changes-production-implementation-guide
- Category: Databases & RLS
- Pillar guide: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## Key points

- The three features are chosen by purpose. Low-latency messages between clients go to Broadcast, online/presence indicators go to Presence, and database changes go to Broadcast (via a trigger) by default — with Postgres Changes reserved for small, low-concurrency cases.
- Postgres Changes runs one authorization check per connected subscriber per change event, and processes changes on a single thread to preserve order. The official docs recommend Broadcast beyond roughly 3,000 concurrent subscribers on the same change.
- Broadcast is fire-and-forget. Events dropped during a reconnect never come back. Refetch the authoritative data when you reach SUBSCRIBED, and carry a version on every event so stale applications are discarded.
- Calling Presence's track() on every mouse move floods the channel. Push high-frequency data like cursor positions to Broadcast, and keep Presence for slow-changing state such as 'online' or 'current page'.
- Quotas bite on concurrent connections, messages per second, channel joins per second, and 100 channels per connection. Free allows 200 connections and 100 messages/sec; Pro allows 500 connections (10,000 with the spend cap removed). Read the limits before you design.

---

Realtime is the feature that most easily *looks* like it works in a demo. Follow any tutorial, subscribe to `postgres_changes`, and within ten minutes another browser reflects your change.

It is also, usually, the first thing to break in production. The reason is straightforward: **a tutorial only ever models "one person watching," while production means hundreds of people watching simultaneously, going into a tunnel on the train, reconnecting, and editing the same row at the same time.**

This article is an implementation guide for moving Supabase Realtime from "it works" to "it doesn't break." Faithful to the official documentation as of 2026-08-16, it covers how to choose among the three features, the correct path for broadcasting database changes, how to close the gap after a reconnect, and where quotas and cost actually bite — with type-safe code you can use as-is.

> **What this article does not cover**: Realtime *authorization* (RLS on `realtime.messages`, `private: true`, `realtime.topic()`) gets a full article of its own in [Authorizing Supabase Realtime with RLS](/blog/supabase-realtime-rls-authorization-broadcast-presence-private-channel-guide). This article assumes authorization is already designed correctly and focuses on **the application design layered on top of it**.

---

## 1. Choose first: the three features are decided by purpose

Supabase Realtime offers three features. The official definitions:

- **Broadcast** — "Send low-latency messages between clients. Perfect for real-time messaging, database changes, cursor tracking, game events, and custom notifications."
- **Presence** — "Track and synchronize user state across clients. Ideal for showing who's online, or active participants."
- **Postgres Changes** — subscribe to database modifications in real time.

The three are not alternatives to one another; **they play different roles**. In practice this table is enough.

| What you want | Feature to use | Why |
| --- | --- | --- |
| Chat, notifications, game events | **Broadcast** | Transient messages. Anything that doesn't need to persist shouldn't go through the database |
| Cursor positions, typing indicators | **Broadcast** (+ throttling) | High frequency. Presence will always flood |
| Who is online, who is viewing this page | **Presence** | Purpose-built for "slow state" |
| Reflecting DB changes in the UI (production, many users) | **Broadcast** (via a DB trigger) | Officially "recommended for most use cases" |
| Reflecting DB changes in the UI (small, internal) | **Postgres Changes** | Minimal setup. But it does not scale |

The last two rows are the most misunderstood point in this whole area. On subscribing to database changes, the official documentation is explicit:

> Broadcast is "the recommended method for scalability and security." Postgres Changes is "a simpler method. It requires less setup, but does not scale as well as Broadcast."

In other words, **`postgres_changes` is an on-ramp, not the production default.** Chapter 5 explains why, from the mechanism up.

---

## 2. Your first channel: connect, subscribe, clean up

Start with the foundation. The most common Realtime bug isn't anything sophisticated — it's **forgetting to unsubscribe**. In React (a client component under the Next.js App Router), `removeChannel` in the cleanup function is mandatory.

```ts
// lib/supabase/client.ts — browser client (@supabase/ssr)
import { createBrowserClient } from "@supabase/ssr";

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
  );
}
```

```tsx
"use client";

import { useEffect } from "react";
import { createClient } from "@/lib/supabase/client";

export function RoomPresenceProbe({ roomId }: { roomId: string }) {
  useEffect(() => {
    const supabase = createClient();
    const channel = supabase.channel(`room:${roomId}:messages`, {
      config: { private: true },
    });

    channel.subscribe();

    // Cleanup. Without this, Strict Mode's double invocation and every page
    // transition stack up channels, quietly approaching the 100-channels-
    // per-connection limit
    return () => {
      void supabase.removeChannel(channel);
    };
  }, [roomId]);

  return null;
}
```

Three things to internalize up front.

1. **Pass `config: { private: true }`.** Only a private channel is authorized by RLS on `realtime.messages`. Forget it and you have a channel with no authorization.
2. **Private channels require `supabase.realtime.setAuth()`.** That's the call that hands the JWT to Realtime.
3. **Give the channel name (topic) meaning.** Structuring it as `room:<id>:messages` lets your RLS policies decompose `realtime.topic()` and match against it.

### Treat subscription status as an event

`subscribe()` reports status through a callback. Swallow it and you can never detect a disconnect.

```ts
channel.subscribe((status, err) => {
  switch (status) {
    case "SUBSCRIBED":
      // Connected. This is your resync hook (chapter 6)
      break;
    case "CHANNEL_ERROR":
      // Authorization error or expired JWT. Send err to your observability stack
      break;
    case "TIMED_OUT":
    case "CLOSED":
      // The client will retry automatically. Show "reconnecting" in the UI
      break;
  }
});
```

---

## 3. Broadcast: there are three send paths

Broadcast splits into three paths depending on where the sender lives. Without sorting this out, you end up **opening a WebSocket when all you wanted was to send one message from the server**.

### 3-1. Over WebSocket (from a subscribed client)

```ts
const channel = supabase.channel("room-1", { config: { private: true } });

channel.subscribe((status) => {
  if (status !== "SUBSCRIBED") return;
  channel.send({
    type: "broadcast",
    event: "shout",
    payload: { message: "Hi" },
  });
});
```

Calling `send()` before subscribing makes the client fall back to HTTP automatically. In the official wording: "Sending a message before subscribing will use HTTP." The behavior changes, so **be deliberate about when you send**.

### 3-2. Over HTTP (send without subscribing)

Opening a WebSocket just to fire one notification is overkill. JS client v2.107.0 and later provide `httpSend`.

```ts
const channel = supabase.channel("test-channel")
await channel.httpSend('cursor-pos', { x: Math.random(), y: Math.random() })
supabase.removeChannel(channel)
```

From a server in another language or runtime, call REST directly.

```bash
# Single message
POST /realtime/v1/api/broadcast/{topic}/events/{event}

# To a private channel
POST /realtime/v1/api/broadcast/{topic}/events/{event}?private=true

# Batch
POST /realtime/v1/api/broadcast
```

**A crucial asymmetry**: private Broadcasts only reach private channels, and public Broadcasts only reach public channels. Forgetting `private=true` produces the worst possible debugging experience — no error, the message simply never arrives.

### 3-3. From the database (send from a trigger)

This is **the production default** for distributing database changes. `realtime.send()` is the low-level API; `realtime.broadcast_changes()` is the higher-level one that assembles a payload shaped like Postgres Changes.

```sql
-- Low level: any JSON to any topic
select realtime.send(
  jsonb_build_object('hello', 'world'),
  'event',
  'topic',
  false  -- private
);
```

```sql
-- High level: broadcast row changes as they are
create or replace function public.your_table_changes()
returns trigger
security definer
language plpgsql
as $$
begin
  perform realtime.broadcast_changes(
    'topic:' || coalesce(NEW.id, OLD.id)::text,  -- topic
    TG_OP,                                        -- event
    TG_OP,                                        -- operation
    TG_TABLE_NAME,                                -- table
    TG_TABLE_SCHEMA,                              -- schema
    NEW,                                          -- new record
    OLD                                           -- old record
  );
  return null;
end;
$$;

create trigger your_table_changes
after insert or update or delete on public.your_table
for each row execute function public.your_table_changes();
```

`realtime.broadcast_changes()` **requires private channels by default**. The official docs state this was done "to prevent security incidents." Which means choosing this path forces you to write RLS on `realtime.messages` — not a constraint so much as **a guardrail that tips the design in the right direction**.

> Adding `set search_path = ''` to a `security definer` function is the safe convention. [SECURITY DEFINER functions and search_path](/blog/supabase-security-definer-function-search-path-guide) covers the reasoning in detail.

### 3-4. Options worth knowing

| Option | How to write it | When it helps |
| --- | --- | --- |
| `self` | `{ config: { broadcast: { self: true } } }` | By default the sender does not receive their own message. Enable it to handle sends and receives on a single receive path and cut branching |
| `ack` | `{ config: { broadcast: { ack: true } } }` | Wait for server acknowledgement. Useful when the UI must reflect send success or failure |
| `replay` | `{ private: true, broadcast: { replay: { since, limit } } }` | **Private channels + database-originated Broadcast only.** Replay recent messages. `limit` maxes at 25; `since` is a millisecond epoch |

Messages are retained for 72–96 hours. `replay` helps with recovery from a **short** disconnect, but **it is not a recovery mechanism for a device that was offline for a long time**. Chapter 6's refetch covers that.

---

## 4. Presence: state you may share, and state you may not

Presence consists of `track()` / `untrack()` / `presenceState()` plus the `sync` / `join` / `leave` events.

```ts
const channel = supabase.channel(`room:${roomId}:presence`, {
  config: { private: true, presence: { key: userId } },
});

channel
  .on("presence", { event: "sync" }, () => {
    const state = channel.presenceState();
    // Returns every client's payload merged, shaped like { "<key>": [{ userId, page }], ... }
  })
  .on("presence", { event: "join" }, ({ newPresences }) => { /* ... */ })
  .on("presence", { event: "leave" }, ({ leftPresences }) => { /* ... */ })
  .subscribe(async (status) => {
    if (status !== "SUBSCRIBED") return;
    await channel.track({ userId, page: "editor" });
  });
```

Here is what the official documentation warns about in bold:

> Don't use for high-frequency updates. Calling `track()` rapidly — for example on every mouse move to share cursor positions — will flood the channel and cause performance problems.

Presence **merges state and distributes it to all participants**, so the cost of a single update scales with the number of participants. High-frequency data like cursors and typing indicators belongs in **Broadcast, throttled on the send side**.

There's one more behavior you will certainly hit in implementation:

> During a `sync` event, you may receive `join` and `leave` events simultaneously, even though no users are joining or leaving. This is expected behavior — Presence reconciles its local state with the server state.

Which means **you must not use `join` / `leave` as an entry/exit log**. A toast saying "Alex joined" fired from `join` will misfire on every reconciliation. Always derive what you display from the **current snapshot** in `presenceState()`, and treat `join` / `leave` purely as a trigger to re-render.

### Accessibility: how do realtime updates reach someone who can't see them?

Realtime UI tends to be **indistinguishable from nothing happening** for screen reader users. Swapping DOM does not trigger announcements. Regions such as a presence bar need `aria-live`.

```tsx
"use client";

export function PresenceBar({ names }: { names: readonly string[] }) {
  return (
    <div
      // polite: does not interrupt the current announcement. Use assertive only for urgent cases
      aria-live="polite"
      aria-atomic="true"
      // Give the region a name, e.g. "Online: 3 people"
      aria-label="Members online"
      className="flex items-center gap-2 text-sm"
    >
      {names.length === 0 ? "No members online" : `Online: ${names.join(", ")}`}
    </div>
  );
}
```

Three practical points:

- **Don't put `aria-live` on the body of incoming chat messages.** Announcing a streaming conversation region on every change makes it unusable. Keep announcements to a **summary** such as "3 new messages," and put the message bodies somewhere reachable by focus.
- **Lower the update rate before announcing.** In practice, feed an `aria-live` region an aggregate value debounced by roughly 300–500 ms.
- **Respect `prefers-reduced-motion`.** Realtime updates pair naturally with highlight animations, which is exactly what affects users with vestibular disorders.

---

## 5. Postgres Changes: understand the mechanism and the limits become visible

`postgres_changes` is the fastest way to subscribe to database changes. Setup is just adding the table to the publication.

```sql
alter publication supabase_realtime add table public.messages;
```

```ts
const channel = supabase
  .channel("changes")
  .on(
    "postgres_changes",
    { event: "UPDATE", schema: "public", table: "messages", filter: "body=eq.hey" },
    (payload) => console.log(payload),
  )
  .subscribe();
```

Filters support `eq` / `neq` / `lt` / `lte` / `gt` / `gte` / `in` (up to 100 values) / `like` / `ilike` / `match` / `imatch` / `is` / `isdistinct`, negation with a `not.` prefix, and AND-combination with commas.

### Three limits, all following from the mechanism

**(1) Throughput does not go up.** The official explanation is precise:

> Realtime performs one authorization check per connected subscriber per change event. Changes are also processed on a single thread to preserve their order, which means larger compute add-ons don't meaningfully increase Postgres Changes throughput.

Read it as: **subscribers × changes** authorization checks, run serially. Hence the recommendation:

> If you expect more than ~3,000 concurrent subscribers on the same changes, use Broadcast to stream database changes instead. Broadcast sends each change once and fans it out to all subscribers, so it scales to far higher connection counts than per-subscriber authorization allows.

**(2) DELETE is not authorized.**

> RLS policies are not applied to `DELETE` statements, because there is no way for Postgres to verify that a user has access to a deleted record.

Distributing delete events to an unbounded audience is, by itself, a potential leak path. Leaning on soft deletes via `deleted_at` (i.e. UPDATE) is safer.

**(3) Filtering DELETE requires `replica identity full`.** The old row isn't carried in the WAL otherwise, and enabling it raises write cost.

### Conclusion: when it is fine to choose `postgres_changes`

- The number of concurrent subscribers is clearly small (internal admin panels, ops dashboards, tens of people)
- The target table changes infrequently
- There is no prospect of user counts jumping

Miss any one of those and lean on `realtime.broadcast_changes()` from chapter 3. **Doing it up front is vastly cheaper than migrating later.**

---

## 6. Production design: dropped events, ordering, idempotency

This is the part that separates a tutorial from production.

Broadcast is **transient**. Events that occur while you are disconnected do not arrive. Mobile networks, tunnels, waking from sleep, backgrounded tabs — disconnection is not an exception, it is **the daily norm**. So hold realtime state in two tiers:

```text
Authoritative data (Postgres)   ← the source of correctness. Refetchable
        ↑ apply deltas
Realtime events (Broadcast)     ← the source of speed. Safe to lose
```

### 6-1. Resync on SUBSCRIBED

`SUBSCRIBED` is where a reconnect completes. Make it the hook that **discards deltas and rebuilds**.

```ts
// lib/realtime/sync.ts — framework-agnostic (subscription control only)
import type { RealtimeChannel, SupabaseClient } from "@supabase/supabase-js";

export interface SyncedChannelOptions<T> {
  readonly channelName: string;
  readonly event: string;
  /** Refetch the authoritative data. Called on every SUBSCRIBED */
  readonly refetch: () => Promise<T>;
  /** Validate the broadcast payload. Return null when invalid */
  readonly parse: (raw: unknown) => T | null;
  readonly onState: (next: T) => void;
}

export function subscribeSynced<T>(
  supabase: SupabaseClient,
  options: SyncedChannelOptions<T>,
): () => void {
  const channel: RealtimeChannel = supabase.channel(options.channelName, {
    config: { private: true },
  });

  // Reconnect race guard: discard refetch results older than the current generation
  let generation = 0;

  channel
    .on("broadcast", { event: options.event }, ({ payload }) => {
      const parsed = options.parse(payload);
      if (parsed === null) return; // Ignore messages that fail validation
      options.onState(parsed);
    })
    .subscribe((status) => {
      if (status !== "SUBSCRIBED") return;
      const current = ++generation;
      void options.refetch().then((state) => {
        if (current === generation) options.onState(state);
      });
    });

  return () => {
    void supabase.removeChannel(channel);
  };
}
```

`generation` looks trivial but is essential. On a flaky connection `SUBSCRIBED` fires several times in quick succession, producing a bug that is brutally hard to reproduce: **a late-returning stale `refetch` overwrites newer state**.

### 6-2. Don't trust the payload

A Broadcast payload is JSON assembled by any client permitted to send on that channel. **RLS governs who may send and receive — not what they may send.** A tampered client can send values with the wrong types, or malicious ones.

Validate at the boundary.

```ts
import { z } from "zod";

const scoreEventSchema = z.object({
  /** Idempotency key. Never apply the same event twice */
  eventId: z.uuid(),
  /** Monotonically increasing. Used to discard stale events */
  revision: z.number().int().nonnegative(),
  gameId: z.uuid(),
  homeScore: z.number().int().min(0).max(999),
  awayScore: z.number().int().min(0).max(999),
});

export type ScoreEvent = z.infer<typeof scoreEventSchema>;

export function parseScoreEvent(raw: unknown): ScoreEvent | null {
  const result = scoreEventSchema.safeParse(raw);
  return result.success ? result.data : null;
}
```

Values that **affect outcomes** — amounts, permissions, state transitions — should never treat a Realtime payload as authoritative in the first place. Let Realtime say only "something changed," and read the value back from the server/database. That is the design least likely to break.

### 6-3. Apply idempotently

Prepare for both possibilities: the same event arriving twice (HTTP fallback, retries, replay), and an older event arriving late.

```ts
interface ScoreState {
  readonly revision: number;
  readonly homeScore: number;
  readonly awayScore: number;
}

/** Pure function — which is why the test is one line */
export function applyScoreEvent(state: ScoreState, event: ScoreEvent): ScoreState {
  // Discard stale or same-revision events (idempotent + order-tolerant)
  if (event.revision <= state.revision) return state;
  return {
    revision: event.revision,
    homeScore: event.homeScore,
    awayScore: event.awayScore,
  };
}
```

```ts
import { describe, expect, it } from "vitest";

describe("applyScoreEvent", () => {
  const base: ScoreState = { revision: 5, homeScore: 2, awayScore: 1 };

  it("applying the same event twice leaves state unchanged", () => {
    const event = { eventId: "…", revision: 5, gameId: "…", homeScore: 9, awayScore: 9 };
    expect(applyScoreEvent(base, event)).toBe(base);
  });

  it("a stale event arriving late does not overwrite newer state", () => {
    const stale = { eventId: "…", revision: 3, gameId: "…", homeScore: 0, awayScore: 0 };
    expect(applyScoreEvent(base, stale)).toBe(base);
  });
});
```

The key move is **extracting the apply logic as a pure function**. Coupled to the WebSocket, neither of these two tests can be written. Extracted, the most fragile part of realtime is testable in milliseconds.

### 6-4. Optimistic updates that can roll back

Updating the sender's UI immediately (optimistic update) dramatically improves perceived speed, but you need to be able to roll back when the server rejects. If you carry `revision`, the authoritative data overwrites on resync, so **rollback reduces to refetching**. No dedicated rollback logic required.

---

## 7. Quotas and cost: read the limits before you design

Realtime is a service where "is it allowed at that scale?" matters more than "does it work?" The official quotas:

| Item | Free | Pro | Pro (no spend cap) | Team | Enterprise |
| --- | --- | --- | --- | --- | --- |
| Concurrent connections | 200 | 500 | 10,000 | 10,000 | 10,000+ |
| Messages per second | 100 | 500 | 2,500 | 2,500 | 2,500+ |
| Channel joins per second | 100 | 500 | 2,500 | 2,500 | 2,500+ |
| Channels per connection | 100 | 100 | 100 | 100 | 100+ |
| Broadcast payload | 256–3,000 KB | same | same | same | 3,000+ KB |
| Postgres Changes payload | 1,024 KB | same | same | same | same |

And on the billing side:

| Item | Free | Pro |
| --- | --- | --- |
| Peak concurrent connections | 200 included | 500 included, then $10 per 1,000 |
| Messages | 2 million/month included | 5 million/month included, then $2.50 per million |

**Three points that bear directly on design:**

1. **"Channel joins per second" is the one people miss.** A synchronized login burst (9 a.m., right after a push notification) has everyone subscribing at once, and you jam on join rate rather than connection count. Stagger subscription starts, or defer subscribing until a page transition actually needs it.
2. **"100 channels per connection" runs dry through missed cleanup.** The `removeChannel` from chapter 2 is also the code that keeps you under this ceiling.
3. **Message counts change by an order of magnitude with throttling.** Cursor sharing at 60 fps is 60 messages per second per user; drop it to 20 fps and it's 20. Same experience, one third the cost.

---

## 8. Observability: make disconnects visible

Realtime failures do not appear in server-side error logs, because **the client is simply going quiet**. Measure at minimum:

```ts
channel.subscribe((status, err) => {
  if (status === "SUBSCRIBED") {
    track("realtime_subscribed", { channel: channelName });
    return;
  }
  if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
    // err carries a message. Never include PII
    track("realtime_disconnected", { channel: channelName, status });
  }
});
```

- **Time to connect** (`subscribe()` call → `SUBSCRIBED`)
- **`CHANNEL_ERROR` rate** (usually an expired JWT or missing authorization; if it's authorization, suspect RLS on `realtime.messages`)
- **Resync count** (executions of `refetch` from chapter 6; a spike means the network or token refresh has a problem)

The classic cause of `CHANNEL_ERROR` is **JWT expiry**. On policy evaluation the official docs explain: the policy is cached during the connection and updated only when a client subscribes to a channel or sends a new `access_token` message; if a new JWT is never received on the channel, the client will be disconnected when the JWT expires. For a dashboard left open for hours, that means **calling `setAuth()` on every token refresh**.

---

## 9. Pitfall checklist

Before production, confirm all of the following.

- [ ] `removeChannel` is called in cleanup (no double subscription even under React Strict Mode)
- [ ] Private channels pass `config: { private: true }` and call `setAuth()`
- [ ] REST Broadcasts pass `?private=true` (forgetting it means "silently never arrives")
- [ ] Presence `join` / `leave` are not used as an entry/exit log (reconciliation misfires)
- [ ] High-frequency updates such as cursors go through Broadcast, throttled — not Presence
- [ ] If you chose `postgres_changes`, you have grounds that concurrent subscribers stay well below 3,000
- [ ] Authoritative data is refetched on reaching `SUBSCRIBED` (with a generation counter discarding stale results)
- [ ] Received payloads are schema-validated and failing messages are dropped
- [ ] Events carry a monotonically increasing revision, and application is idempotent
- [ ] The apply logic is extracted as a pure function with tests
- [ ] Realtime update regions have `aria-live`, and announcements are summaries
- [ ] Disconnects, resyncs, and channel errors are instrumented
- [ ] You have an estimate showing peak concurrent connections and messages/sec fit within the plan's quotas

---

## Conclusion: put speed in the layer that's allowed to drop

Making Supabase Realtime production-grade comes down to one line.

**Put correctness in Postgres and speed in Realtime.** A realtime event is a notification that something changed — not the source of correctness. If events can drop, arrive out of order, or arrive twice, and refetching the authoritative data always restores the correct state, then realtime survives the unstable networks of production.

Concretely: choose features by purpose (Broadcast-centric, Postgres Changes only for small scale), push database changes through `realtime.broadcast_changes()` onto private channels, and give the receiving side the three-piece set of validation, idempotent application, and resync. Only then does realtime graduate from "a feature that works in a demo" to "a feature you can trust in production."

And don't forget the layer above all of it: **authorization**. Who may subscribe to which room, and who may send — expressing that in RLS rather than in the goodwill of the application is what completes a realtime design on Supabase.
