Skip to main content
Databases & RLS
Supabase
リアルタイム
TypeScript
Next.js
PostgreSQL
アーキテクチャ設計

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
Reading time
18 min read
Author
友田 陽大
Share

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. 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 wantFeature to useWhy
Chat, notifications, game eventsBroadcastTransient messages. Anything that doesn't need to persist shouldn't go through the database
Cursor positions, typing indicatorsBroadcast (+ throttling)High frequency. Presence will always flood
Who is online, who is viewing this pagePresencePurpose-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 ChangesMinimal 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.

// 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!,
  );
}
"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.

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)

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.

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.

# 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.

-- Low level: any JSON to any topic
select realtime.send(
  jsonb_build_object('hello', 'world'),
  'event',
  'topic',
  false  -- private
);
-- 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 covers the reasoning in detail.

3-4. Options worth knowing

OptionHow to write itWhen 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.

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.

"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.

alter publication supabase_realtime add table public.messages;
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:

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.

// 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.

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.

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,
  };
}
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:

ItemFreeProPro (no spend cap)TeamEnterprise
Concurrent connections20050010,00010,00010,000+
Messages per second1005002,5002,5002,500+
Channel joins per second1005002,5002,5002,500+
Channels per connection100100100100100+
Broadcast payload256–3,000 KBsamesamesame3,000+ KB
Postgres Changes payload1,024 KBsamesamesamesame

And on the billing side:

ItemFreePro
Peak concurrent connections200 included500 included, then $10 per 1,000
Messages2 million/month included5 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:

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.

Frequently asked questions

Which should I actually use — Broadcast, Presence, or Postgres Changes?
Use Broadcast for low-latency messages between clients, Presence to share who is online, and — for reflecting database changes in the UI — Broadcast sent from a database trigger. The official docs state plainly that Broadcast is recommended for most use cases when streaming database changes. Postgres Changes is the easiest to set up but does not scale, so keep it to admin panels and internal tools where the number of concurrent subscribers is genuinely small.
How far does Postgres Changes scale?
The official docs say to use Broadcast if you expect more than roughly 3,000 concurrent subscribers on the same changes. The reason is mechanical: Postgres Changes performs one authorization check per connected subscriber per change event, and processes changes on a single thread to preserve their order — so adding compute does not meaningfully increase throughput. Broadcast sends each change once and fans it out, which scales to far higher connection counts.
How do I recover events dropped during a reconnect?
Broadcast messages are transient and carry no delivery guarantee, so events that occurred while you were disconnected are generally gone. You close the gap yourself. When the subscription status reaches SUBSCRIBED, refetch the authoritative data from the database and rebuild state, and put a monotonically increasing version (updated_at or a revision number) on every event so stale ones are discarded. On a private channel, database-originated Broadcasts can additionally be replayed with the replay option (up to 25 messages).
Can I trust the data that arrives in a Realtime message?
No. 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. Always run received payloads through schema validation (Zod or similar) and drop anything that fails instead of rendering it. Values that affect outcomes — amounts, permissions, state transitions — should be read from the authoritative server/database data rather than trusted from a Realtime payload.
Where does the cost spike?
Billing is driven by peak concurrent connections and message count. The Free plan includes 200 concurrent connections and 2 million messages per month; Pro includes 500 concurrent connections (overage $10 per 1,000 connections) and 5 million messages per month (overage $2.50 per million). The pattern that spikes is 'every user subscribes to the same channel and sends at high frequency'. Throttling cursor and typing indicators and scoping subscriptions per room drops message counts by an order of magnitude.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Supabase-based applications, from design through production operations

Realtime design (choosing between Broadcast / Presence / Postgres Changes, and staying consistent across reconnects), Auth flows, JWT signing keys and MFA, idempotent webhook handling in Edge Functions, and Storage upload paths and cost design. Built solo on a real mobile + web product with authorization pushed down into the database via RLS — so the app stays reliable, traceable, and easy to change.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading