Supabase Realtime 実践:WebSocket 接続管理と再接続戦略
午前3時、スマホが震えた。
クライアントからのメッセージだった。「あなたたちのチャットアプリ、ユーザーがメッセージがよく遅れるって言ってる。ページを更新しないと新しいメッセージが見えない時があるらしい」
画面を見つめながら、胃が縮む思いだった。この問題はあまりに馴染み深い——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つの異なるリアルタイム機能」とあるが、どれを使えばいいのか?
結論から言うと、3つの核心的な違いはデータがどこにあるか:
| 機能 | データ保存 | 典型的なシナリオ | 遅延 |
|---|---|---|---|
| Broadcast | メモリのみ、永続化なし | クライアント間のメッセージ、マウス位置の同期 | 最低 |
| Presence | メモリのキーバリューストア(CRDT) | オンラインユーザー一覧、コラボレーション状態の同期 | 低 |
| Postgres Changes | PostgreSQL データベース | チャットメッセージ、注文ステータスの変更 | 中程度 |
表を見ただけではまだ抽象的かもしれない。別の言い方をしよう。
Broadcast は「伝言ゲーム」のようなもの。あなたが一言叫ぶと、聞いている全員が聞こえるが、言い終わると消えて痕跡は残らない。「瞬間的」なデータに適している——例えばコラボレーション編集時のカーソル位置。マウスを動かすと他人のカーソルがついてくるが、5秒前にカーソルがどこにあったかは誰も気にしない。
Presence は「出席簿」のようなもの。各人が入ってきて出席を取り、自分の状態(オンライン、オフライン、編集中…)を書くと、全員がそのリストを見られる。重要なのは:状態が自動的に同期され、CRDT(Conflict-free Replicated Data Types)に基づいているため、2人が同時に同じ行を変更しても競合しない。
Postgres Changes は「データベースリスナー」だ。データベースのデータが変わると通知を受け取る。これは最も「重い」が、最も信頼性が高い——データが PostgreSQL に保存されているため、切断して再接続してもメッセージは失われない。
どう選ぶ? 簡単な判断方法
自分に2つの質問をしてみる:
-
データを永続化する必要があるか?
- 永続化が必要 → Postgres Changes
- 永続化が不要 → 2番目の質問へ
-
データは「イベント」か「状態」か?
- イベント(あるアクションが発生した)→ Broadcast
- 状態(誰かが何をしているか)→ Presence
例えばチャットアプリの場合:「メッセージ送信」はイベントなので Broadcast または Postgres Changes、「入力中」は状態なので Presence、「新着メッセージ通知」は永続化が必要なので Postgres Changes を使う。
あのコラボレーションホワイトボードプロジェクトでは、最終的にこう割り当てた:
- 描画軌跡の同期 → Broadcast(高速、保存不要)
- 誰がオンラインか、誰がどのエリアを描いているか → Presence(状態同期)
- ホワイトボードの内容保存 → Postgres Changes(データベースに永続化)
二、リアルタイム購読の実践: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年の新機能)
前述の通りハートビート機構は自動だが、問題がある:時々接続が「生きているように見える」が、実際にはメッセージを受け取れない。
Supabase は2026年4月に 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 より早く問題を発見できる。
デフォルトでは、SDK は3回ハートビートが失敗してから再接続をトリガーするかもしれない。heartbeatCallback を使えば、最初の失敗で能動的に処理できる——リアルタイム性が高いアプリ(オンラインコラボレーションなど)では、数十秒の「偽の接続」時間を減らせる。
実際のテストでは、heartbeatCallback を有効にすると、切断検出から復旧までの平均時間が45秒から12秒程度に短縮された。
worker: true:ブラウザのバックグラウンド切断問題の解決
もう一つのよくある問題:ユーザーがタブを切り替えると、接続が音もなく切れる。
原因はブラウザの throttling 機構にある。Chrome や Firefox はリソースを節約するため、バックグラウンドタブの WebSocket 接続を制限する——ハートビートパケットが遅延したり停止したりして、サーバーがクライアントを「死んだ」と判断する。
Supabase は2026年5月に worker: true パラメータを追加し、WebSocket 接続を Web Worker で実行できるようにした:
const channel = supabase.channel('messages-channel', {
config: {
worker: true // Web Worker で実行
}
})
Web Worker はブラウザの throttling の影響を受けない。タブがバックグラウンドにあっても、ハートビートパケットは正常に送信される。
使用シナリオ:
- コラボレーション系アプリ(ユーザーが頻繁にタブを切り替える可能性)
- カスタマーサポートシステム(オペレーターが複数の会話を同時に処理)
- バックグラウンド同期タスク(ユーザーが長時間見ない可能性)
注意:Web Worker を有効にするとメモリ使用量が増える(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:サーバーから「切断中の変更」をプッシュ
バックエンドの協力が必要。「未プッシュの変更」をデータベースに保存しておき、クライアントが再接続したら一括プッシュする。より複雑だが、より信頼性が高い。
小規模プロジェクトでは方法1で十分。重要なのは:再接続成功後すぐに同期し、ユーザーに手動更新を待たせないことだ。
四、Broadcast と Presence:チャットルームだけじゃない
前の2章は主に Postgres Changes を扱った。この章では残りの2つの機能——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 サーバーの主要なパラメータ
Supabase 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 + データベース連携) | 中 |
| Pusher | $29〜 | 中(純粋な WebSocket) | 低 |
| Firebase Realtime DB | 従量課金 | 中(Firebase エコシステムに依存) | 低 |
Supabase の利点:Postgres Changes はデータベースの変更を直接監視でき、追加のプッシュロジックが不要;RLS が自動的に適用され、セキュリティロジックが統一される。欠点:PostgreSQL の仕組みを理解する必要があり、学習曲線がやや急。
既に Supabase を Auth と Storage に使っているなら、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秒)
• 再接続成功後、見逃したメッセージを即座に同期
heartbeatCallback とは? どんな時に使う?
worker: true は何を解決する?
Realtime 購読は RLS ルールに従う?
本番環境で注意すべき設定パラメータは?
• DB_POOL_SIZE:PostgreSQL 接続プールサイズ、デフォルト 10
• DB_QUEUE_TARGET:一括プッシュ待機時間、デフォルト 100ms
• SUBSCRIBER_LIMIT:単一チャンネルの最大購読者数、デフォルト 200
マルチテナントシステムでチャンネル爆発を防ぐには?
Supabase Realtime と Pusher/Firebase の比較は?
8 min read · 公開日: 2026年5月12日 · 更新日: 2026年5月13日
Supabase 実践ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Supabase Realtime 実践:WebSocket 接続管理と再接続戦略
Supabase Realtime の実践テクニックを詳しく解説。WebSocket 接続管理、再接続戦略、Postgres Changes によるリアルタイム購読をカバー。Broadcast、Presence、Postgres Changes の3つのコア機能の選び方と本番環境でのベストプラクティスを習得します
第 9 / 11 記事
次の記事
Supabase Realtime 実践ガイド:3つのモード比較とコラボレーションアプリ開発
Supabase Realtime は3つのリアルタイムモードを提供します:Postgres Changes、Presence、Broadcast。本記事では各モードの特徴を比較し、完全なコラボレーションアプリのコード例とRLSセキュリティ設定を解説します。
第 11 / 11 記事
関連記事
Supabase 入門ガイド:PostgreSQL + Auth + Storage でオールインワン バックエンド
Supabase 入門ガイド:PostgreSQL + Auth + Storage でオールインワン バックエンド
Supabase データベース設計:テーブル構造、リレーションとRLS完全ガイド
Supabase データベース設計:テーブル構造、リレーションとRLS完全ガイド
Supabase Auth 実践ガイド:メール認証、OAuth、セッション管理

コメント
GitHubアカウントでログインしてコメントできます