Realtime

Postgres の変更(INSERT / UPDATE / DELETE)を WebSocket で即座に配信する仕組み。 Broadcast / Presence と合わせて、チャットや共同編集が実装できる。

3 つの機能

どれも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()

制限と料金

パフォーマンス Tips

Server-Sent Events 不要

WebSocket(フォールバックで long polling)で動くため、ブラウザ側の準備が要らない。

失敗パターン

症状原因
イベントが来ないpublication にテーブル追加してない / RLS 拒否
UPDATE で old が空replica identity full が無い
たまに切れるJWT 期限切れ。再 setAuth
Channel error同名 Channel に複数 subscribe / RLS の SQL エラー
料金が爆発大量 Broadcast / 無限ループ

セルフホストの場合

設計の指針

状態は DB(Postgres Changes)一時的な信号は Broadcast誰がいるかは Presence」と使い分ける。全部を Postgres Changes でやろうとすると DB が重くなる。