リアルタイム機能は、デモでは一番簡単に「動いて見える」機能です。チュートリアル通りに postgres_changes を購読すれば、10分で他のブラウザに変更が反映されます。
そして本番で最初に壊れるのも、たいていここです。理由ははっきりしています。チュートリアルは「1人が見ている状態」しか扱わないのに、本番は「数百人が同時に見ていて、電車でトンネルに入り、再接続し、同じ行を同時に書き換える」状態だからです。
この記事は、Supabase Realtimeを「動く」から「壊れない」へ持っていくための実装ガイドです。公式ドキュメント(2026-08-16時点)に忠実に、3つの機能の選び方、DBの変更を配信する正しい経路、再接続時の取りこぼしをどう埋めるか、そしてクォータとコストがどこで効いてくるかまでを、そのまま使える型安全なコードで扱います。
この記事が扱わないこと:Realtimeの認可(
realtime.messagesへのRLS、private: true、realtime.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点だけ、最初に押さえてください。
config: { private: true }を付ける。 プライベートチャンネルにして初めてrealtime.messagesのRLSで認可されます。付け忘れると認可が効かないチャンネルになります。- プライベートチャンネルには
supabase.realtime.setAuth()が必要。 JWTをRealtime側に渡す呼び出しです。 - チャンネル名(トピック)に意味を持たせる。
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イベントの最中に、実際には誰も参加・退出していないのにjoinとleaveを同時に受け取ることがある。これは想定内の挙動で、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は「動くかどうか」より先に「その規模で許されるか」を確認すべきサービスです。公式のクォータはこうです。
| 項目 | Free | Pro | Pro(上限解除) | Team | Enterprise |
|---|---|---|---|---|---|
| 同時接続数 | 200 | 500 | 10,000 | 10,000 | 10,000+ |
| メッセージ/秒 | 100 | 500 | 2,500 | 2,500 | 2,500+ |
| チャンネル参加/秒 | 100 | 500 | 2,500 | 2,500 | 2,500+ |
| 接続あたりチャンネル数 | 100 | 100 | 100 | 100 | 100+ |
| Broadcastペイロード | 256〜3,000 KB | 同左 | 同左 | 同左 | 3,000+ KB |
| Postgres Changesペイロード | 1,024 KB | 同左 | 同左 | 同左 | 同左 |
課金側の単位はこうです。
| 項目 | Free | Pro |
|---|---|---|
| ピーク同時接続数 | 200 込み | 500 込み、以降 1,000接続あたり $10 |
| メッセージ数 | 月200万 込み | 月500万 込み、以降 100万あたり $2.50 |
設計判断に直結する3点を挙げます。
- 「チャンネル参加/秒」は見落とされがち。 一斉ログイン(朝9時、通知プッシュ直後)で全ユーザーが同時に購読を張ると、接続数ではなく参加レートで詰まります。購読の開始をずらす、あるいは購読を画面遷移で必要になった時点まで遅延させます。
- 「接続あたり100チャンネル」は後片付け漏れで枯れる。 第2章の
removeChannelは、この上限を守るためのコードでもあります。 - メッセージ数はスロットルで桁が変わる。 カーソル共有を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におけるリアルタイム設計の完成形です。