Realtime
Postgres の変更(INSERT / UPDATE / DELETE)を WebSocket で即座に配信する仕組み。 Broadcast / Presence と合わせて、チャットや共同編集が実装できる。
3 つの機能
- Postgres Changes — テーブルの行変更を購読
- Broadcast — クライアント間で任意イベントをやり取り
- Presence — オンラインユーザの状態同期
どれもChannel という単位で動く。
Postgres Changes(CDC)
有効化
Database → Replication で対象テーブルを Realtime に追加するか、SQL で:
-- レプリケーションスロットに追加
alter publication supabase_realtime add table posts;
UPDATE / DELETE で full row を返したい場合
alter table posts replica identity full;
クライアント購読
const channel = supabase
.channel("posts-changes")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "posts" },
(payload) => {
console.log(payload)
// payload.eventType: "INSERT" | "UPDATE" | "DELETE"
// payload.new: 新しい行
// payload.old: 古い行(DELETE / UPDATE 時)
}
)
.subscribe()
// 後で
supabase.removeChannel(channel)
イベント別購読
.on("postgres_changes", { event: "INSERT", schema: "public", table: "posts" }, ...)
.on("postgres_changes", { event: "UPDATE", schema: "public", table: "posts" }, ...)
.on("postgres_changes", { event: "DELETE", schema: "public", table: "posts" }, ...)
条件フィルタ
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "messages",
filter: `room_id=eq.${roomId}`, // 特定ルームのみ
},
...
)
フィルタ演算子: eq / neq / gt / gte / lt / lte / in。
RLS が効く(重要)
Realtime もRLS の対象。SELECT ポリシーで読めない行は配信されない。
publication に追加してあっても、RLS が拒否すれば届かない。
React で購読 + リスト同期
"use client"
import { useEffect, useState } from "react"
import { supabase } from "@/lib/supabase/client"
type Post = { id: string, title: string, created_at: string }
export function PostList() {
const [posts, setPosts] = useState<Post[]>([])
useEffect(() => {
let mounted = true
supabase.from("posts").select().order("created_at", { ascending: false })
.then(({ data }) => { if (mounted) setPosts(data ?? []) })
const ch = supabase
.channel("realtime-posts")
.on("postgres_changes",
{ event: "INSERT", schema: "public", table: "posts" },
({ new: row }) => setPosts((p) => [row as Post, ...p]))
.on("postgres_changes",
{ event: "UPDATE", schema: "public", table: "posts" },
({ new: row }) => setPosts((p) => p.map(x => x.id === (row as Post).id ? row as Post : x)))
.on("postgres_changes",
{ event: "DELETE", schema: "public", table: "posts" },
({ old: row }) => setPosts((p) => p.filter(x => x.id !== (row as Post).id)))
.subscribe()
return () => { mounted = false; supabase.removeChannel(ch) }
}, [])
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
Broadcast(任意イベント)
DB を介さず、クライアント間で直接イベント送受信。 カーソル位置 / タイピング中表示 / ホットなアクションに使う。
const ch = supabase.channel("room-1", {
config: { broadcast: { self: false } }, // 自分には送り返さない
})
// 受信
ch.on("broadcast", { event: "cursor" }, ({ payload }) => {
console.log(payload.x, payload.y)
})
ch.subscribe()
// 送信
ch.send({
type: "broadcast",
event: "cursor",
payload: { x: 100, y: 200 },
})
ack(配信確認)
supabase.channel("room", {
config: { broadcast: { ack: true } },
})
const result = await ch.send({ ... })
// result === "ok" なら届いた
Presence(誰がオンラインか)
各クライアントが状態を共有。「3 人がこの部屋にいます」「Alice が編集中」が作れる。
const ch = supabase.channel("room-1", {
config: { presence: { key: userId } }, // ユーザ識別子
})
// 全状態
ch.on("presence", { event: "sync" }, () => {
const state = ch.presenceState()
console.log("現在のユーザ:", state)
})
ch.on("presence", { event: "join" }, ({ key, newPresences }) => {
console.log(`${key} 入室`, newPresences)
})
ch.on("presence", { event: "leave" }, ({ key, leftPresences }) => {
console.log(`${key} 退室`, leftPresences)
})
// 自分の状態を発信
ch.subscribe(async (status) => {
if (status === "SUBSCRIBED") {
await ch.track({ user: "Alice", online_at: new Date().toISOString() })
}
})
// 状態更新
await ch.track({ user: "Alice", typing: true })
// 退出(自動でも、明示でも)
await ch.untrack()
Channel の購読状態
ch.subscribe((status, err) => {
// status: "SUBSCRIBED" | "TIMED_OUT" | "CLOSED" | "CHANNEL_ERROR"
if (status === "CHANNEL_ERROR") console.error(err)
})
Channel の認証
ログイン後の token を Realtime にも渡す:
// token を Realtime にセット(自動で行われるが、手動も可)
supabase.realtime.setAuth(session.access_token)
クライアントの初期設定
const supabase = createClient(URL, KEY, {
realtime: {
params: {
eventsPerSecond: 10, // レート制限
},
},
})
典型ユースケース
チャット
テーブルに INSERT → Realtime で配信 → クライアントがリストに追加。 Broadcast で「タイピング中」、Presence で「在室者」。
共同編集
各クライアントが編集差分を Broadcast で送信。 最終確定だけ DB に書き込む。
カーソル / マウス位置共有
Broadcast で 30〜60Hz でカーソル位置を投げ合う。DB は使わない。
通知
notifications テーブルに INSERT → ユーザ ID で個別フィルタして配信。
supabase.channel(`notifications:${userId}`)
.on("postgres_changes", {
event: "INSERT",
schema: "public",
table: "notifications",
filter: `user_id=eq.${userId}`,
}, ({ new: row }) => toast(row.message))
.subscribe()
制限と料金
- Free: 同時接続 200、200 メッセージ/秒
- Pro: 同時接続 500、500 メッセージ/秒(増やせる)
- Channel ごとの参加人数も制限あり
- Broadcast / Presence はメッセージ数で消費される
パフォーマンス Tips
- 1 ページに 1〜2 Channelに抑える(トークン管理が楽)
- ページネーションと併用: 古いものは Realtime しない
- カーソル位置などはthrottleして送信回数を抑える
- RLS が複雑だと毎回評価コストがかかる
- 更新頻度が極端に高いものは独自 WebSocket / Phoenix / Yjs も検討
Server-Sent Events 不要
WebSocket(フォールバックで long polling)で動くため、ブラウザ側の準備が要らない。
失敗パターン
| 症状 | 原因 |
|---|---|
| イベントが来ない | publication にテーブル追加してない / RLS 拒否 |
| UPDATE で old が空 | replica identity full が無い |
| たまに切れる | JWT 期限切れ。再 setAuth |
| Channel error | 同名 Channel に複数 subscribe / RLS の SQL エラー |
| 料金が爆発 | 大量 Broadcast / 無限ループ |
セルフホストの場合
- Realtime サーバ(Phoenix / Elixir)が別コンテナで動く
- クラスタ化で水平スケール可
- WAL から変更を読み取って配信
「状態は DB(Postgres Changes)、一時的な信号は Broadcast、 誰がいるかは Presence」と使い分ける。全部を Postgres Changes でやろうとすると DB が重くなる。