メインコンテンツへスキップ
データベース・RLS
Supabase
リアルタイム
TypeScript
Next.js
PostgreSQL
アーキテクチャ設計

Supabase Realtime 実装ガイド:Broadcast・Presence・Postgres Changes の使い分けと、本番で壊れないリアルタイム設計

Supabase Realtimeの3機能を「どれをいつ使うか」から設計する実装ガイド。Broadcastの3つの送信経路(WebSocket・httpSend・DBトリガのrealtime.broadcast_changes)、Presenceを高頻度更新に使ってはいけない理由、Postgres Changesが約3,000購読者で頭打ちになる仕組み、再接続時のギャップ埋めと冪等な適用、プラン別クォータとコストまで、公式ドキュメント準拠の型安全な実コードで解説します。

公開日
読了時間
21分
著者
友田 陽大
シェア

リアルタイム機能は、デモでは一番簡単に「動いて見える」機能です。チュートリアル通りに postgres_changes を購読すれば、10分で他のブラウザに変更が反映されます。

そして本番で最初に壊れるのも、たいていここです。理由ははっきりしています。チュートリアルは「1人が見ている状態」しか扱わないのに、本番は「数百人が同時に見ていて、電車でトンネルに入り、再接続し、同じ行を同時に書き換える」状態だからです。

この記事は、Supabase Realtimeを「動く」から「壊れない」へ持っていくための実装ガイドです。公式ドキュメント(2026-08-16時点)に忠実に、3つの機能の選び方、DBの変更を配信する正しい経路、再接続時の取りこぼしをどう埋めるか、そしてクォータとコストがどこで効いてくるかまでを、そのまま使える型安全なコードで扱います。

この記事が扱わないこと:Realtimeの認可realtime.messages へのRLS、private: truerealtime.topic())は、Supabase RealtimeをRLSで認可するで1本まるごと扱っています。本記事は認可が正しく設計されている前提で、その上に載せるアプリケーション設計を扱います。


1. まず選定:3つの機能は「用途」で決まる

Supabase Realtimeは3つの機能を提供します。公式の定義はこうです。

  • Broadcast — 「クライアント間で低遅延メッセージを送る。リアルタイムメッセージング、データベース変更、カーソル追跡、ゲームイベント、カスタム通知に最適」
  • Presence — 「クライアント間でユーザーの状態を追跡・同期する。誰がオンラインか、誰が参加中かの表示に理想的」
  • Postgres Changes — データベースの変更をリアルタイムに購読する

3つは代替関係ではなく、役割が違います。実務での選定はこの表で足ります。

やりたいこと使う機能理由
チャット、通知、ゲームイベントBroadcast一過性のメッセージ。DBに残す必要がないものはDBを経由させない
カーソル位置、タイピング中表示Broadcast(+ スロットル)高頻度。Presenceでやると必ず溢れる
誰がオンラインか、誰がこのページを見ているかPresence「遅い状態」の同期に最適化されている
DBの変更をUIに反映(本番・不特定多数)Broadcast(DBトリガ経由)公式が「ほとんどのユースケースで推奨」
DBの変更をUIに反映(小規模・社内)Postgres Changesセットアップが最小。ただしスケールしない

最後の2行が、この記事でいちばん誤解されているポイントです。公式ドキュメントは、データベース変更の購読について明確にこう書いています。

Broadcast は「スケーラビリティとセキュリティのために推奨される方法」。Postgres Changes は「よりシンプルな方法。セットアップは少なくて済むが、Broadcast ほどスケールしない」

つまり postgres_changes は入門用の近道であって、本番の既定ではありません。なぜそうなるのかは第5章で仕組みから説明します。


2. 最初の1本:接続・購読・後片付け

まずは土台です。Realtimeで最も多いバグは高度なものではなく、購読を解除し忘れることです。React(Next.js App Router のクライアントコンポーネント)では、クリーンアップ関数での removeChannel が必須になります。

// lib/supabase/client.ts — ブラウザ用クライアント(@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();

    // 後片付け。これが無いと Strict Mode の二重実行や画面遷移のたびに
    // チャンネルが積み上がり、接続あたり100チャンネルの上限に静かに近づく
    return () => {
      void supabase.removeChannel(channel);
    };
  }, [roomId]);

  return null;
}

3点だけ、最初に押さえてください。

  1. config: { private: true } を付ける。 プライベートチャンネルにして初めて realtime.messages のRLSで認可されます。付け忘れると認可が効かないチャンネルになります。
  2. プライベートチャンネルには supabase.realtime.setAuth() が必要。 JWTをRealtime側に渡す呼び出しです。
  3. チャンネル名(トピック)に意味を持たせる。 room:<id>:messages のように構造化しておくと、RLSポリシー側で realtime.topic() を分解して照合できます。

購読ステータスは「イベント」として扱う

subscribe() はコールバックでステータスを返します。ここを握りつぶすと、切断に気づけません。

channel.subscribe((status, err) => {
  switch (status) {
    case "SUBSCRIBED":
      // 接続確立。ここが「再同期のフック」になる(第6章)
      break;
    case "CHANNEL_ERROR":
      // 認可エラーやJWT失効。err を可観測性基盤へ
      break;
    case "TIMED_OUT":
    case "CLOSED":
      // クライアントは自動再接続を試みる。UIには「再接続中」を出す
      break;
  }
});

3. Broadcast:送信経路は3つある

Broadcastは「送る側がどこにいるか」で3つの経路に分かれます。ここを整理せずに書くと、サーバーから送りたいだけなのにWebSocketを張るといった無駄が生まれます。

3-1. WebSocket 経由(購読中のクライアントから)

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

購読前に send() を呼ぶと、クライアントは自動的にHTTPにフォールバックします。公式の表現では「購読前にメッセージを送るとHTTPを使う」。挙動が変わるので、送信タイミングは意識して書きます

3-2. HTTP 経由(購読しないで送るだけ)

「1回通知を投げたいだけ」でWebSocketを張るのは過剰です。JSクライアント v2.107.0 以降は httpSend があります。

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

サーバー側(別言語・別ランタイム)からはRESTを直接叩けます。

# 単発
POST /realtime/v1/api/broadcast/{topic}/events/{event}

# プライベートチャンネル宛
POST /realtime/v1/api/broadcast/{topic}/events/{event}?private=true

# バッチ
POST /realtime/v1/api/broadcast

重要な非対称性:プライベートのBroadcastはプライベートチャンネルにしか届かず、パブリックのBroadcastはパブリックチャンネルにしか届きません。private=true の付け忘れは「エラーにならず、ただ届かない」という最悪のデバッグ体験になります。

3-3. データベース経由(トリガから送る)

DBの変更を配信する本番の既定がこれです。realtime.send() が低レベルAPI、realtime.broadcast_changes() が「Postgres Changesと同じ形のペイロードを組み立ててくれる」高レベルAPIです。

-- 低レベル: 任意のJSONを任意のトピックへ
select realtime.send(
  jsonb_build_object('hello', 'world'),
  'event',
  'topic',
  false  -- private
);
-- 高レベル: 行の変更をそのまま配信する
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()既定でプライベートチャンネルを要求します。公式は「セキュリティインシデントを防ぐためにそうした」と明記しています。つまりこの経路を選んだ時点で、realtime.messages のRLSを書くことが強制されます。これは制約ではなく、設計を正しい方向に倒してくれるガードレールです。

security definer の関数には set search_path = '' を付けるのが安全側の作法です。理由はSECURITY DEFINER関数とsearch_pathで詳しく扱っています。

3-4. 知っておくと効くオプション

オプション書き方使いどころ
self{ config: { broadcast: { self: true } } }既定では送信者に自分のメッセージは返らない。「送信も含めて一本の受信パスで処理したい」ときに有効化して分岐を減らす
ack{ config: { broadcast: { ack: true } } }サーバーの受領確認を待つ。送信の成否をUIに出したいとき
replay{ private: true, broadcast: { replay: { since, limit } } }プライベートチャンネル + DB発Broadcast限定で直近メッセージを再生。limit は最大25、since はミリ秒エポック

メッセージの保持期間は72〜96時間です。replay は「短い切断からの復帰」には効きますが、長時間オフラインだった端末の復旧手段にはなりません。そこは第6章の再取得で埋めます。


4. Presence:使ってよい状態と、使ってはいけない状態

Presenceは track() / untrack() / presenceState() の3つと、sync / join / leave の3イベントで構成されます。

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

channel
  .on("presence", { event: "sync" }, () => {
    const state = channel.presenceState();
    // { "<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" });
  });

公式ドキュメントが太字で警告しているのがここです。

高頻度の更新には使わないこと。カーソル位置を共有するために毎マウスムーブで track() を呼ぶようなことをすると、チャンネルが溢れてパフォーマンス問題を起こす。

Presenceは状態を全参加者にマージして配る仕組みなので、更新1回のコストが参加者数に比例します。カーソルやタイピングインジケータのような高頻度データは、Broadcastに逃がして、送信側でスロットルするのが正解です。

もう1つ、実装時に必ず踏む挙動があります。

sync イベントの最中に、実際には誰も参加・退出していないのに joinleave を同時に受け取ることがある。これは想定内の挙動で、Presenceがローカル状態をサーバー状態と突き合わせて調停している。

つまり join / leave を「入退室ログ」として使ってはいけません。「Aさんが入室しました」というトーストを join で出すと、再調停のたびに誤爆します。表示は常に presenceState()現在のスナップショットから導出し、join / leave は再描画のトリガとしてのみ扱ってください。

アクセシビリティ:リアルタイム更新は「見えない人」にどう届くか

リアルタイムUIは、スクリーンリーダー利用者にとって何も起きていないのと同じになりがちです。DOMが差し替わっても読み上げは走りません。在席表示のような領域には aria-live を付けます。

"use client";

export function PresenceBar({ names }: { names: readonly string[] }) {
  return (
    <div
      // polite: 現在の読み上げを中断しないassertive は緊急時のみ
      aria-live="polite"
      aria-atomic="true"
      // 「オンライン: 3人を領域名として与える
      aria-label="オンラインのメンバー"
      className="flex items-center gap-2 text-sm"
    >
      {names.length === 0 ? "オンラインのメンバーはいません" : `オンライン: ${names.join("、")}`}
    </div>
  );
}

3点が実務上の勘所です。

  • チャットの新着メッセージ本文に aria-live を付けない。 会話が流れる領域を毎回読み上げると使い物になりません。読み上げるのは「新着3件」のような要約に留め、本文はフォーカス移動で読める場所に置きます。
  • 更新頻度を下げてから読ませる。 aria-live 領域は、300〜500ms程度のデバウンスをかけた集計値を入れるのが実用的です。
  • アニメーションは prefers-reduced-motion を尊重する。 リアルタイム更新はハイライト演出と相性がよく、そのぶん前庭障害のあるユーザーに影響します。

5. Postgres Changes:仕組みを知れば限界が見える

postgres_changes は最短でDBの変更を購読できます。準備はパブリケーションへのテーブル追加だけです。

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();

フィルタは eq / neq / lt / lte / gt / gte / in(最大100値)/ like / ilike / match / imatch / is / isdistinct が使え、not. で否定、カンマ区切りでAND結合できます。

限界は3つ、いずれも仕組みから来る

(1) スループットが上がらない。 公式の説明が正確です。

Realtimeは、変更イベントごとに、接続中の購読者ごとに認可チェックを1回行う。変更は順序を保つために単一スレッドで処理されるため、大きなコンピュートアドオンを積んでもPostgres Changesのスループットは実質的に向上しない。

購読者数 × 変更数の認可チェックが直列に走る、と読めば十分です。だから公式はこう推奨します。

同一の変更に約3,000を超える同時購読者が見込まれるなら、Broadcastでデータベース変更をストリームすること。Broadcastは各変更を1回だけ送って全購読者にファンアウトするため、購読者ごとの認可が許すよりはるかに高い接続数までスケールする。

(2) DELETE の認可が効かない。

RLSポリシーは DELETE 文には適用されない。削除されたレコードにユーザーがアクセスできたかをPostgresが検証する方法がないため。

削除イベントを不特定多数に配る設計は、それだけで情報漏れの経路になり得ます。deleted_at による論理削除(=UPDATE)に寄せるのが安全です。

(3) DELETE をフィルタしたいなら replica identity full が要る。 これはWAL上に旧行が載らないためで、有効にすると書き込みコストが上がります。

結論:postgres_changes を選んでよい条件

  • 同時購読者が明確に少ない(社内管理画面、運用ダッシュボード、数十人規模)
  • 対象テーブルの更新頻度が低い
  • 将来ユーザー数が跳ねる見込みがない

1つでも外れるなら、第3章の realtime.broadcast_changes() に寄せてください。後から移行するより、最初から寄せるほうが圧倒的に安いです。


6. 本番設計:取りこぼし・順序・冪等性

ここからが、チュートリアルと本番を分ける部分です。

Broadcastは一過性です。切断中に発生したイベントは届きません。モバイル回線、トンネル、スリープ復帰、タブのバックグラウンド化——切断は例外ではなく日常です。したがって、リアルタイムの状態は次の2階建てで持ちます。

権威データ(Postgres)        ← 正しさの源。再取得できる
        ↑ 差分を当てる
リアルタイムイベント(Broadcast) ← 速さの源。落ちてもよい

6-1. SUBSCRIBED で再同期する

再接続の完了地点は SUBSCRIBED です。ここを「差分を捨てて作り直すフック」にします。

// lib/realtime/sync.ts — フレームワーク非依存(純粋な購読制御のみ)
import type { RealtimeChannel, SupabaseClient } from "@supabase/supabase-js";

export interface SyncedChannelOptions<T> {
  readonly channelName: string;
  readonly event: string;
  /** 権威データの再取得。SUBSCRIBED のたびに呼ばれる */
  readonly refetch: () => Promise<T>;
  /** ブロードキャストのペイロード検証。不正なら null を返す */
  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 },
  });

  // 再接続の競合対策: 世代番号より古い refetch の結果は破棄する
  let generation = 0;

  channel
    .on("broadcast", { event: options.event }, ({ payload }) => {
      const parsed = options.parse(payload);
      if (parsed === null) return; // 検証に落ちたメッセージは無視する
      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 は地味ですが必須です。不安定な回線では SUBSCRIBED が短時間に複数回発火し、遅れて返ってきた古い refetch が新しい状態を上書きするという再現困難なバグを生みます。

6-2. ペイロードは信用しない

Broadcastのペイロードは、そのチャンネルへの送信を許可された任意のクライアントが組み立てたJSONです。**RLSが守るのは「誰が送受信できるか」であって「何を送るか」ではありません。**改ざんされたクライアントは、型の合わない、あるいは悪意ある値を送れます。

境界でスキーマ検証します。

import { z } from "zod";

const scoreEventSchema = z.object({
  /** 冪等性キー。同じイベントを2回適用しない */
  eventId: z.uuid(),
  /** 単調増加。古いイベントを捨てるために使う */
  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;
}

金額・権限・状態遷移など結果に影響する値は、そもそもRealtimeのペイロードを正としてはいけません。Realtimeは「変わったよ」という通知に留め、値はサーバー/DBから読み直す——これが最も壊れにくい設計です。

6-3. 冪等に適用する

同じイベントが2回届く可能性(HTTPフォールバック、リトライ、replay)と、古いイベントが遅れて届く可能性の両方に備えます。

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

/** 純粋関数。だからテストが1行で書ける */
export function applyScoreEvent(state: ScoreState, event: ScoreEvent): ScoreState {
  // 古い、または同一リビジョンのイベントは捨てる(冪等 + 順序耐性)
  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("同じイベントを2回適用しても状態は変わらない", () => {
    const event = { eventId: "…", revision: 5, gameId: "…", homeScore: 9, awayScore: 9 };
    expect(applyScoreEvent(base, event)).toBe(base);
  });

  it("遅れて届いた古いイベントは新しい状態を上書きしない", () => {
    const stale = { eventId: "…", revision: 3, gameId: "…", homeScore: 0, awayScore: 0 };
    expect(applyScoreEvent(base, stale)).toBe(base);
  });
});

適用ロジックを純粋関数として切り出すのが要点です。WebSocketに依存したままでは、この2つのテストは書けません。切り出せば、リアルタイムの最も壊れやすい部分がミリ秒でテストできます。

6-4. 楽観的更新は「巻き戻せる」形で

送信者側のUIを即座に更新する(楽観的更新)と体感は劇的に良くなりますが、サーバーが拒否したときに巻き戻せる必要があります。revision を持たせておけば、再同期時に権威データが上書きしてくれるため、巻き戻しは「再取得」に還元されます。専用のロールバックロジックを書く必要はありません。


7. クォータとコスト:設計前に上限を見る

Realtimeは「動くかどうか」より先に「その規模で許されるか」を確認すべきサービスです。公式のクォータはこうです。

項目FreeProPro(上限解除)TeamEnterprise
同時接続数20050010,00010,00010,000+
メッセージ/秒1005002,5002,5002,500+
チャンネル参加/秒1005002,5002,5002,500+
接続あたりチャンネル数100100100100100+
Broadcastペイロード256〜3,000 KB同左同左同左3,000+ KB
Postgres Changesペイロード1,024 KB同左同左同左同左

課金側の単位はこうです。

項目FreePro
ピーク同時接続数200 込み500 込み、以降 1,000接続あたり $10
メッセージ数月200万 込み月500万 込み、以降 100万あたり $2.50

設計判断に直結する3点を挙げます。

  1. 「チャンネル参加/秒」は見落とされがち。 一斉ログイン(朝9時、通知プッシュ直後)で全ユーザーが同時に購読を張ると、接続数ではなく参加レートで詰まります。購読の開始をずらす、あるいは購読を画面遷移で必要になった時点まで遅延させます。
  2. 「接続あたり100チャンネル」は後片付け漏れで枯れる。 第2章の removeChannel は、この上限を守るためのコードでもあります。
  3. メッセージ数はスロットルで桁が変わる。 カーソル共有を60fpsで送れば1ユーザー毎秒60メッセージ、20fpsに落とせば20です。同じ体験で1/3になります。

8. 可観測性:切断が「見える」ようにする

リアルタイム機能の障害は、サーバー側のエラーログに出ません。クライアントが静かに切れているだけだからです。最低限これを計測してください。

channel.subscribe((status, err) => {
  if (status === "SUBSCRIBED") {
    track("realtime_subscribed", { channel: channelName });
    return;
  }
  if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
    // err にはメッセージが入る。PIIを載せないこと
    track("realtime_disconnected", { channel: channelName, status });
  }
});
  • 接続確立までの時間subscribe() 呼び出し → SUBSCRIBED
  • CHANNEL_ERROR の発生率(多くはJWT失効か認可漏れ。認可が原因ならrealtime.messages のRLSを疑う)
  • 再同期の回数(第6章の refetch 実行数。跳ねていれば回線かトークン更新に問題がある)

CHANNEL_ERROR の代表的な原因がJWTの失効です。公式はポリシー評価について「ポリシーは接続中キャッシュされ、クライアントがチャンネルを購読するか、新しい access_token メッセージを送ったときにのみ更新される。新しいJWTがチャンネルに届かなければ、JWTの期限が切れた時点でクライアントは切断される」と説明しています。長時間開きっぱなしのダッシュボードでは、トークン更新のたびに setAuth() を呼ぶ必要があります。


9. 落とし穴チェックリスト

本番前に、これだけは確認してください。

  • removeChannel をクリーンアップで呼んでいる(React Strict Modeでも二重購読しない)
  • プライベートチャンネルに config: { private: true } を付け、setAuth() を呼んでいる
  • REST経由のBroadcastで ?private=true を付けている(付け忘れは「無言で届かない」)
  • Presenceの join / leave を入退室ログとして使っていない(sync の調停で誤爆する)
  • カーソル等の高頻度更新をPresenceではなくBroadcastで送り、スロットルしている
  • postgres_changes を選んだ場合、同時購読者が3,000を大きく下回る根拠がある
  • SUBSCRIBED 到達時に権威データを再取得している(世代番号で古い結果を破棄)
  • 受信ペイロードをスキーマ検証し、落ちたメッセージを捨てている
  • イベントに単調増加のリビジョンがあり、適用が冪等である
  • 適用ロジックが純粋関数として切り出され、テストがある
  • リアルタイム更新領域に aria-live があり、読み上げが要約になっている
  • 切断・再同期・チャンネルエラーを計測している
  • ピーク同時接続数とメッセージ/秒がプランのクォータ内に収まる見積もりがある

まとめ:速さは「落ちてもよい層」に置く

Supabase Realtimeを本番品質にする設計は、突き詰めると1行で言えます。

正しさはPostgresに、速さはRealtimeに置く。 リアルタイムイベントは「変わった」という通知であって、正しさの源ではありません。イベントが落ちても、順序が乱れても、二重に届いても、権威データを再取得すれば必ず正しい状態に戻る——そう設計されていれば、リアルタイム機能は本番の不安定なネットワークに耐えます。

具体的には、機能選定を用途で決め(Broadcast中心、Postgres Changesは小規模限定)、DBの変更は realtime.broadcast_changes() でプライベートチャンネルに流し、受信側は検証・冪等適用・再同期の3点セットを持つ。ここまでやって初めて、リアルタイムは「デモで動く機能」から「本番で信頼できる機能」になります。

そして忘れてはいけないのが、この上に載る認可です。誰がどのルームを購読でき、誰が送信できるのか——それを「アプリの善意」ではなくRLSで表現するところまでが、Supabaseにおけるリアルタイム設計の完成形です。

よくある質問

Broadcast・Presence・Postgres Changes は結局どれを使えばいいですか?
クライアント間で低遅延にメッセージを飛ばすならBroadcast、誰がオンラインかを共有するならPresence、DBの変更をUIに反映したいなら『Broadcast(DBトリガから送る)』が既定です。公式ドキュメントもデータベース変更の配信について「ほとんどのユースケースでBroadcastを推奨」と明記しています。Postgres Changesはセットアップが最も簡単な代わりにスケールしないため、同時購読者が少ない管理画面や社内ツールに限って使うのが安全です。
Postgres Changes はどれくらいの規模まで使えますか?
公式ドキュメントは「同一の変更に約3,000を超える同時購読者が想定されるならBroadcastを使え」としています。理由は仕組みにあります。Postgres Changesは変更イベントごとに購読者ごとの認可チェックを実行し、さらに順序を保つために単一スレッドで処理するため、コンピュートを増やしてもスループットが実質的に伸びません。Broadcastは変更を1回だけ送って多数へファンアウトするので、はるかに高い接続数まで伸びます。
再接続でイベントを取りこぼしたときはどう復旧しますか?
Broadcastは配信保証のない一過性のメッセージなので、切断中のイベントは基本的に戻りません。実装側で埋めます。購読ステータスがSUBSCRIBEDになった時点でDBから権威データを再取得して状態を作り直し、各イベントに単調増加のバージョン(updated_atやリビジョン番号)を載せて、古いイベントは捨てる設計にします。プライベートチャンネルであれば、DB発のBroadcastに限りreplayオプション(最大25件)で直近のメッセージを再生することもできます。
Realtime のメッセージに載ってくるデータは信用してよいですか?
いいえ。Broadcastのペイロードは、そのチャンネルに送信を許可された任意のクライアントが組み立てたJSONです。RLSが守るのは『誰が送受信できるか』であって『何を送るか』ではありません。受信側は必ずスキーマ検証(Zod等)を通し、検証に落ちたメッセージはUIに反映せず捨ててください。金額・権限・状態遷移など結果に影響する値は、Realtimeのペイロードではなくサーバー/DBから読んだ権威データを正とします。
コストはどこで跳ねますか?
課金の軸はピーク同時接続数とメッセージ数です。Freeプランは同時接続200・月間200万メッセージ、Proは同時接続500(超過分は1,000接続あたり$10)・月間500万メッセージ(超過分は100万あたり$2.50)が含まれます。跳ねやすいのは『全ユーザーが同じチャンネルを購読し、高頻度に送る』設計です。カーソルやタイピング表示は送信頻度を間引き(スロットル)、購読範囲をルーム単位に絞ることでメッセージ数は桁で下がります。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

Supabase を土台にしたアプリを、設計から本番運用まで承ります

Realtime の設計(Broadcast / Presence / Postgres Changes の使い分けと再接続時の整合)、Auth の認証フロー・JWT署名鍵・MFA、Edge Functions での冪等な Webhook 受信、Storage のアップロード経路とコスト設計まで。認可を RLS で DB に寄せた設計を、モバイル+Web の実プロダクトで単独構築した知見で、落ちない・追える・変更しやすいアプリを実装します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

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

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

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

あわせて読みたい