Supabase Realtime 実践:WebSocket 接続管理と切断再接続戦略
スマホが震えた。
クライアントからのメッセージだ。「うちのチャットアプリ、ユーザーからメッセージが遅れることが多い。ページを更新しないと新着が見えない、と言われている」
画面を見つめて、胸がざわついた。この問題、あまりにも馴染み深い——WebSocket が切れたのに、フロントエンドが気づいていない。ユーザーは入力を続け、送信したつもりになっている。実際にはメッセージは途中で消えている。
初めて Supabase Realtime を使ったときも、同じ罠にはまった。当時は共同ホワイトボードを作っていて、データベース変更の購読なんて数行で済むと思っていた:
supabase.channel('board').on('postgres_changes', ...).subscribe()
ところがリリースから2日も経たないうちに、同僚から「同期がよく止まる。描きかけの線が突然消える」と報告が来た。
調査すると、WebSocket 接続は静かに切れていた。エラーも警告もなく、ただ「死んでいた」。そのとき初めて気づいた。リアルタイム購読は購読コードを書くだけでは足りない。接続管理こそが本番の主役だ、と。
この記事では、自分が踏んだ坑と見つけた解決策を整理する。重点は WebSocket 接続のライフサイクル管理——世の中のチュートリアルが最も触れない部分だ。まず3機能の選定、次に Postgres Changes 購読の実装、最後に本番環境の切断再接続戦略と設定最適化を扱う。
一、Supabase Realtime の3つの機能、どれを使うべきか?
Supabase Realtime に触れたばかりの頃、3つの用語に混乱した。Broadcast、Presence、Postgres Changes。ドキュメントには「3つの異なるリアルタイム機能」とあるが、結局どれを使えばいいのか?
結論から言うと、核心的な違いはデータがどこに存在するかだ。
| 機能 | データ保存 | 典型的なシナリオ | 遅延 |
|---|---|---|---|
| Broadcast | メモリのみ、永続化なし | クライアント間メッセージ、マウス位置同期 | 最低 |
| Presence | メモリのキーバリュー(CRDT) | オンラインユーザー一覧、協調状態の同期 | 低 |
| Postgres Changes | PostgreSQL データベース | チャットメッセージ、注文ステータス変更 | 中程度 |
表だけではまだ抽象的かもしれない。別の言い方をしよう。
Broadcast は「伝声筒」のようなものだ。叫べば聞いている全員に届く。しかし言い終われば消え、痕跡は残らない。「一瞬だけ意味がある」データ向き——協調編集のカーソル位置など。マウスを動かせば他人のカーソルも動く。5秒前にどこにあったかは誰も気にしない。
Presence は「出席簿」だ。入場して自分の状態(オンライン、オフライン、編集中……)を書くと、全員がそのリストを見られる。状態は自動同期され、CRDT(Conflict-free Replicated Data Types)ベースなので、2人が同時に同じ行を触っても競合しない。
Postgres Changes は「データベースリスナー」だ。DB のデータが変われば通知が来る。最も「重い」が最も信頼できる。データは PostgreSQL に残るので、切断して再接続してもメッセージは失われない。
どう選ぶ? シンプルな判断法
2つの質問を自分に投げかけよう。
-
データを永続化する必要があるか?
- 必要 → Postgres Changes
- 不要 → 次の質問へ
-
データは「イベント」か「状態」か?
- イベント(何かが起きた)→ Broadcast
- 状態(誰かが何をしているか)→ Presence
例:チャットアプリでは「メッセージ送信」はイベント(Broadcast または Postgres Changes)、「入力中」は状態(Presence)、「新着通知」は永続化が必要(Postgres Changes)。
共同ホワイトボードでは最終的にこう割り当てた。
- 描画軌跡の同期 → Broadcast(速い、保存不要)
- 誰がオンラインか、誰がどのエリアを描いているか → Presence(状態同期)
- ホワイトボード内容の保存 → Postgres Changes(DB に永続化)
二、リアルタイム購読の実践:Postgres Changes
Postgres Changes を選んだら、最初に** publication を有効化**する。
Supabase はデフォルトで全テーブルの変更をブロードキャストしない。リソース消費が大きすぎるからだ。「このテーブルを監視する」と明示する必要がある。
-- Supabase SQL Editor で実行
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
実行後、messages テーブルの INSERT、UPDATE、DELETE がブロードキャストされる。
購読コードの書き方
チャットルームの新着メッセージをリアルタイム配信する完全例:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://your-project.supabase.co',
'your-anon-key'
)
// チャンネル作成と購読
const channel = supabase
.channel('messages-channel') // チャンネル名は自由
.on(
'postgres_changes',
{
event: 'INSERT', // 新規追加のみ監視
schema: 'public',
table: 'messages'
},
(payload) => {
console.log('新着メッセージ:', payload.new)
// payload.new は新しく挿入された行
appendMessage(payload.new)
}
)
.subscribe((status) => {
console.log('購読状態:', status)
})
// コンポーネントのアンマウント時にクリーンアップ
// channel.unsubscribe()
シンプルに見えるが、落とし穴がいくつかある。
落とし穴1:event パラメータ
event は 'INSERT'、'UPDATE'、'DELETE'、または全イベントの '*'。新着だけなら '*' は使わない。不要なトラフィックを省ける。
落とし穴2:payload の構造
payload は行全体ではなくオブジェクトだ。
payload.new:新データ(INSERT/UPDATE で有効)payload.old:旧データ(UPDATE/DELETE で有効、replica identity が必要)payload.eventType:イベント種別payload.schema、payload.table:ソース情報
落とし穴3:Row Level Security が有効
見落とされがちな点だ。Realtime 購読も RLS ルールに従う。
RLS を設定していれば、ユーザーは権限のある変更だけ受け取る。例えば messages で参加中のメッセージだけ見える制限なら、Realtime もその分だけプッシュする。全件送ってフロントでフィルタするわけではない。
これが Supabase Realtime の大きな利点だ。セキュリティロジックを二重に書かなくていい。
旧データ取得(replica identity)
デフォルトでは UPDATE/DELETE の payload.old は空だ。「誰が何をどう変えたか」が必要なら replica identity を有効化する。
ALTER TABLE messages REPLICA IDENTITY FULL;
ただし書き込みコストと WAL サイズが増える。本番では本当に必要か慎重に評価しよう。
三、WebSocket 接続管理の落とし穴
冒頭の問題に戻る。WebSocket が切れたのに、フロントエンドが知らない。
Supabase Realtime は Phoenix Channels を使い、接続状態の変化でコールバックが走る。能動的に監視しないと、メッセージは一切届かない。
接続状態一覧
購読コールバックの status には次の値がある。
| 状態 | 意味 | 対応 |
|---|---|---|
SUBSCRIBED | 購読成功 | 通常動作、メッセージ受信 |
CHANNEL_ERROR | 接続エラー | ログ記録、再接続試行 |
TIMED_OUT | タイムアウト(無応答) | ネットワーク変動の可能性、再接続 |
CLOSED | 接続クローズ | ユーザー切断またはサーバー側クローズ |
一見明快だが、落とし穴がある。状態遷移が速すぎて処理が間に合わないことがある。
ネットワークが揺れると、一瞬で CHANNEL_ERROR → CLOSED → SUBSCRIBED(自動再接続成功)を通過し、途中の障害に気づかないこともある。
後からグローバルな状態監視を入れ、変化のたびに記録するようにした。
const channel = supabase
.channel('messages-channel')
.on('postgres_changes', { ... }, handler)
.subscribe((status, err) => {
logConnectionStatus(status, err) // 状態とタイムスタンプを記録
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
showReconnectingToast() // ユーザーに通知
}
if (status === 'SUBSCRIBED') {
hideReconnectingToast()
syncMissedMessages() // 切断中に失われたメッセージを補完
}
})
ハートビート:接続が生きているかどう判断する?
Supabase Realtime には内部ハートビートがある(ソースは keep_alive.ex)。サーバーが定期的にパケットを送り、クライアントが確認を返す。
クライアントが連続で応答しなければ、サーバーは接続死亡と判断して切断する。逆に、クライアントが一定時間パケットを受け取れなければ、タイムアウトで再接続が走る。
ハートビートを手動処理する必要はない。Supabase SDK が自動で行う。本当に気にすべきは、タイムアウト後の再接続戦略だ。
heartbeatCallback:ハートビート状態の能動監視(2026年の新機能)
ハートビートは自動だが、問題がある。接続は生きているように見えるのに、実際にはメッセージが届かないことがある。
2026年4月、Supabase は heartbeatCallback を追加し、ハートビート状態を監視できるようにした。
const channel = supabase.channel('messages-channel', {
config: {
heartbeatCallback: (status) => {
console.log('ハートビート状態:', status)
// status の値:
// - 'ok': 正常
// - 'timeout': サーバー無応答、切断の可能性
// - 'error': ハートビート失敗
if (status === 'timeout') {
// SDK の自動処理を待たず能動的に再接続
channel.unsubscribe()
setTimeout(() => channel.subscribe(), 1000)
}
}
}
})
利点は** SDK より早く問題を検知できる**ことだ。
デフォルトでは3回失敗してから再接続するかもしれない。heartbeatCallback なら最初の失敗で処理できる。リアルタイム性の高い協調アプリでは、数十秒の「偽接続」を短縮できる。
実測では、有効化後に切断検知から復旧までの平均が45秒から12秒程度に短縮された。
worker: true:ブラウザバックグラウンド切断の解決
もう一つの定番問題。タブを切り替えると、接続が音もなく切れる。
原因はブラウザの throttling だ。Chrome/Firefox はバックグラウンドタブの WebSocket を制限し、ハートビートが遅延・停止する。サーバーはクライアントを「死んだ」と判断する。
2026年5月、Supabase は worker: true を追加し、WebSocket を Web Worker で動かせるようにした。
const channel = supabase.channel('messages-channel', {
config: {
worker: true // Web Worker で実行
}
})
Web Worker は throttling の影響を受けにくい。タブがバックグラウンドでもハートビートは送れる。
向いているシナリオ:
- 協調アプリ(タブ切り替えが多い)
- カスタマーサポート(複数会話を同時処理)
- バックグラウンド同期(長時間画面を見ない)
注意:Worker を有効にするとメモリが増える。シンプルなアプリでは不要だが、リアルタイム重視ならコスパの良い最適化だ。
実測:worker: true なしでタブを5分バックグラウンドにすると、ハートビート成功率が98%から63%に低下。有効化後は96%以上を維持。
切断再接続:指数バックオフ vs 即時再接続
Supabase のデフォルト自動再接続は指数バックオフだ。1秒、2秒、4秒……最大30秒程度。
メリットはサーバー過負荷時に再接続嵐を防げること。デメリットはユーザーが長く待つこと。
協調アプリ(ホワイトボード、共同編集)では、より積極的な戦略を使う。
// デフォルトの指数バックオフに頼らず手動再接続
let reconnectAttempts = 0
const MAX_RECONNECT = 10
function handleDisconnect() {
if (reconnectAttempts >= MAX_RECONNECT) {
showFatalError('接続を復旧できません。ページを更新してください')
return
}
// 最初は速く、後は徐々に遅く
const delay = reconnectAttempts < 3 ? 1000 : 3000
setTimeout(() => {
reconnectAttempts++
channel.subscribe() // 再度購読
}, delay)
}
再接続後、切断中のメッセージはどうする?
最も頭が痛い問題だ。30秒切断中に10件送られていたら、どう補う?
方法1:フロントエンドから API で補完
再接続成功後、直ちに API を呼び、「最後に受け取ったメッセージ ID 以降」を取得する。
// 最後に受け取ったメッセージ ID を保持
let lastMessageId = null
function syncMissedMessages() {
supabase
.from('messages')
.select('*')
.gt('id', lastMessageId)
.order('created_at', { ascending: true })
.then(({ data }) => {
// 見逃したメッセージを追加
appendMessages(data)
lastMessageId = data[data.length - 1]?.id
})
}
方法2:サーバーが「切断中の変更」をプッシュ
バックエンド協力が必要。「未プッシュの変更」を DB に蓄え、再接続後に一括送信する。複雑だがより信頼できる。
小規模なら方法1で十分。重要なのは再接続成功直後に同期し、ユーザーに手動更新を求めないことだ。
四、Broadcast と Presence:チャットルームだけじゃない
前2章は Postgres Changes が中心。この章では Broadcast と Presence を扱う。
Broadcast:協調エディタのカーソル同期
共同編集で他人のカーソル位置が見えると体験が良い。Broadcast が最適だ。
// 自分のカーソル位置を送信
const broadcastChannel = supabase.channel('editor-cursors')
// 他人のカーソルを監視
broadcastChannel
.on('broadcast', { event: 'cursor-move' }, (payload) => {
updateRemoteCursor(payload.userId, payload.x, payload.y)
})
.subscribe()
// 自分が動いたらブロードキャスト
document.addEventListener('mousemove', (e) => {
broadcastChannel.send({
type: 'broadcast',
event: 'cursor-move',
payload: {
userId: currentUser.id,
x: e.clientX,
y: e.clientY
}
})
})
注意点:
broadcastChannel.send()は能動送信で、購読コールバックではない- チャンネル名は自由。エディタごとに分ければ隔離できる
- カーソル位置は永続化不要。Broadcast の「送って忘れる」特性に合う
Presence:誰がオンラインか一目で
Presence は「状態系」情報向き。オンラインユーザー一覧の例:
const presenceChannel = supabase.channel('online-users', {
config: {
presence: {
key: 'user_id' // ユーザーを一意に識別
}
}
})
presenceChannel
.on('presence', { event: 'sync' }, () => {
const state = presenceChannel.presenceState()
// state はオブジェクト。キーは user_id、値は状態配列
renderOnlineUsers(Object.keys(state))
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
// 新規参加
showToast(`${newPresences[0].user_name} が参加しました`)
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
// 離脱
showToast(`${leftPresences[0].user_name} が離脱しました`)
})
.subscribe()
// オンライン時に自分の状態を登録
presenceChannel.track({
user_id: currentUser.id,
user_name: currentUser.name,
online_at: new Date().toISOString()
})
track() で「ここにいる」と伝える。状態は全購読者に自動同期され、CRDT ベースなので競合を心配しない。
プライベートチャンネル:購読者を制限
デフォルトでは anon key を持つ誰でも公開チャンネルを購読できる。チーム専用スペースなどでは制限が必要だ。
Supabase は RLS Policy でチャンネルアクセスを制御できる。
-- realtime スキーマで Policy を作成
CREATE POLICY "Only team members can join private channel"
ON realtime.channels
FOR ALL
USING (
-- ユーザーがチームに属するか確認
EXISTS (
SELECT 1 FROM team_members
WHERE team_id = channel.team_id
AND user_id = auth.uid()
)
);
チームメンバーだけが private-team-xxx を購読でき、他者は拒否される。
五、本番環境:知っておくべき設定パラメータ
ローカルでは問題なくても、本番で障害が出る。原因は設定にあることが多い。
Realtime サーバーの主要パラメータ
デフォルトは多くのプロジェクトで十分だが、高並行ではチューニングが必要だ。
| パラメータ | デフォルト | 推奨 | 作用 |
|---|---|---|---|
DB_POOL_SIZE | 10 | 並行接続数に応じて調整 | PostgreSQL 接続プール |
DB_QUEUE_TARGET | 100ms | 下げると遅延減、CPU 増 | 一括プッシュの待機時間 |
SUBSCRIBER_LIMIT | 200 | ユーザー数に応じて調整 | 単一チャンネルの最大購読者 |
遅延が目立つなら DB_QUEUE_TARGET を下げる(例:50ms)。代償は変更チェックが頻繁になり、CPU が上がる。
マルチテナントの接続制限
よくある坑:テナントごとにチャンネルを作ると、総数が爆発する。
Supabase Realtime はプロジェクト全体の購読数に上限がある(Pro プランで5000同時購読)。1000テナント×平均5人オンラインなら、境界ギリギリだ。
対策:
- チャンネル統合:テナントごとに独立チャンネルは不要。1チャンネルで
filter分離 - 選択的購読:現在のテナントチャンネルだけ購読
// filter で現在テナントのメッセージだけ受信
supabase
.channel('tenant-messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: 'tenant_id=eq.123' // テナント123のみ
},
handler
)
.subscribe()
競合比較:Supabase vs Pusher vs Firebase
主要リアルタイム方案の簡単な比較:
| 方案 | コスト | 機能 | 学習曲線 |
|---|---|---|---|
| Supabase Realtime | 無料(Pro $25/月) | 高(3-in-1 + DB 連携) | 中 |
| Pusher | $29〜 | 中(純 WebSocket) | 低 |
| Firebase Realtime DB | 従量課金 | 中(Firebase エコシステム) | 低 |
Supabase の強み:Postgres Changes が DB 変更を直接監視でき、追加プッシュロジックが不要。RLS が自動適用され、セキュリティが統一される。弱み:PostgreSQL の理解が必要で、学習曲線がやや急。
Auth と Storage で Supabase を使っているなら Realtime 追加は自然だ。単純 WebSocket だけなら Pusher の方が早いかもしれない。
まとめ
核心は3点だ。
機能を選ぶ:Broadcast はイベント、Presence は状態同期、Postgres Changes は永続化。永続化が必要か、イベントか状態か——この2問で答えが出る。
接続を管理する:購読成功は永続的受信を保証しない。状態変化を監視し、「再接続中」をユーザーに示し、再接続後に漏れを即同期する。これでリアルタイム体験が安定する。
設定を調整する:本番はローカルの拡大版ではない。DB_POOL_SIZE や QUEUE_TARGET は遅延とスループットに直結する。リリース前にデフォルト値を確認しておこう。
最初に踏んだ坑——WebSocket 切断に気づけない——は、状態監視と再接続通知で解決した。切断時は「接続を復旧中」と表示され、再接続後はメッセージが自動補完される。手動更新は不要だ。
Supabase Realtime 未経験なら、まず Postgres Changes から始めるのがおすすめだ。最もシンプルで、最もよく使われる。以前書いた Auth シリーズ(メール認証、OAuth 設定)と組み合わせれば、リアルタイムバックエンドが完成する。
質問はコメントで。詳しくは Supabase 公式ドキュメントを。アーキテクチャの説明は明快で、Phoenix Channels と PG2 adapter を深く知りたければソースも読む価値がある。
FAQ
Supabase Realtime の3つの機能の違いは?
WebSocket 切断後にどう復旧する?
• 最初の数回は高速再接続(1秒)
• その後は徐々に間隔を延ばす(3秒)
• 再接続成功後に見逃したメッセージを即座に同期
Realtime 購読は RLS ルールに従う?
本番環境で注目すべき設定パラメータは?
• DB_POOL_SIZE:PostgreSQL 接続プールサイズ、デフォルト 10
• DB_QUEUE_TARGET:一括プッシュの待機時間、デフォルト 100ms
• SUBSCRIBER_LIMIT:単一チャンネルの最大購読者数、デフォルト 200
マルチテナントシステムでチャンネル爆発を防ぐには?
Supabase Realtime と Pusher/Firebase の比較は?
8分で読めます · 公開日: 2026年5月12日 · 更新日: 2026年6月15日
Supabase 実践ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Supabase Realtime 実践:3つのモード比較とコラボアプリ開発
Supabase Realtime は3つのリアルタイムモードを提供します:Postgres Changes、Presence、Broadcast。本記事では各モードの特徴を比較分析し、完全なコラボアプリのコード例と RLS セキュリティ設定を紹介します。
第 5 / 10 記事
次の記事
Supabase Storage 実践:ファイルアップロード、CDN、アクセス制御
Supabase Storage の完全実践ガイド。3 つのアクセス制御モードの比較、TUS 分割アップロード、Smart CDN の最適化テクニック、R2/S3 との価格比較を解説。React のコード例とトラブルシューティングも掲載。
第 7 / 10 記事
関連記事
Supabase 入門:PostgreSQL + Auth + Storage のオールインワンバックエンド
Supabase 入門:PostgreSQL + Auth + Storage のオールインワンバックエンド
Supabase データベース設計:テーブル構造・リレーション・Row Level Security 完全ガイド
Supabase データベース設計:テーブル構造・リレーション・Row Level Security 完全ガイド
Supabase Auth 実践:メール認証・OAuth・セッション管理
コメント
GitHubアカウントでログインしてコメントできます