言語を切り替える
テーマを切り替える

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メモリ Key-Value ストレージ(CRDT)オンラインユーザー一覧、協調状態同期
Postgres ChangesPostgreSQL データベースチャットメッセージ、注文ステータス変更中程度

表だけではまだ抽象的かもしれない。別の言い方をしよう:

Broadcast は「伝声ゲーム」のようなもの。あなたが叫ぶと、聞いている全員が聞こえるが、言い終わったら消えて跡形もない。「一瞬の」データに適している——例えば協調編集中のカーソル位置。マウスを動かすと他の人のカーソルも動くが、5秒前にカーソルがどこにあったかは誰も気にしない。

Presence は「出席簿」のようなもの。各人が入ってきて出席を取り、自分の状態(オンライン、オフライン、編集中…)を書く。全員がこのリストを見られる。重要なのは、状態が自動的に同期され、CRDT(競合フリー複製データ型)に基づいているため、2人が同時に同じ行を変更しても競合しないことだ。

Postgres Changes は「データベースリスナー」だ。データベースのデータが変わると通知が届く。これが最も「重い」が、最も信頼性も高い——データが PostgreSQL に保存されるため、切断して再接続してもメッセージは失われない。

どう選ぶ? 簡単な判断方法

2つの質問を自分に問いかける:

  1. データは永続化が必要か?

    • 永続化が必要 → Postgres Changes
    • 永続化が不要 → 2つ目の質問へ
  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.schemapayload.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 が自動的に処理する。本当に気にすべきなのは、タイムアウト後の再接続戦略だ。

切断再接続:指数バックオフ vs 即時再接続

Supabase のデフォルト自動再接続は指数バックオフを使用:最初の再接続は1秒待機、2回目は2秒、3回目は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:チャットルームだけじゃない

これまでは主に 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_SIZE10コンカレント接続数に応じて調整PostgreSQL 接続プールサイズ
DB_QUEUE_TARGET100ms下げるとレイテンシー削減、CPU増加メッセージ一括プッシュの待機時間
SUBSCRIBER_LIMIT200ユーザー数に応じて調整単一チャンネルの最大購読者数

メッセージ遅延が目立つ場合は、DB_QUEUE_TARGET を下げる(例えば 50ms)。代償はサーバーがより頻繁に変更をチェックするため、CPU 使用率が上がる。

マルチテナントアーキテクチャの接続制限

よくある落とし穴:マルチテナントシステムで、各テナントに1つのチャンネルを割り当てると、総チャンネル数がすぐに爆発する。

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つの機能の違いは?
Broadcast はクライアント間のイベント伝達(カーソル同期など)、Presence は状態同期(オンラインユーザーなど)、Postgres Changes はデータベース変更の監視に使用します。選び方は2つの質問:データは永続化が必要か、イベントか状態か。
WebSocket 切断後にどう復旧する?
Supabase はデフォルトで指数バックオフ再接続を使用します。カスタム戦略も可能:

• 最初の数回は高速再接続(1秒)
• 徐々に遅く(3秒)
• 再接続成功後に見逃したメッセージを即座に同期
Realtime 購読は RLS ルールに従う?
はい、Realtime 購読も Row Level Security ルールに従います。ユーザーは閲覧権限がある変更だけを受信し、セキュリティロジックを二重に書く必要はありません。
本番環境で注目すべき設定パラメータは?
3つの主要パラメータ:

• DB_POOL_SIZE:PostgreSQL 接続プールサイズ、デフォルト 10
• DB_QUEUE_TARGET:一括プッシュ待機時間、デフォルト 100ms
• SUBSCRIBER_LIMIT:単一チャンネルの最大購読者数、デフォルト 200
マルチテナントシステムでチャンネル爆発を防ぐには?
各テナントに独立したチャンネルを作成せず、filter パラメータで1つのチャンネル内でメッセージをフィルタリングします。例:filter: "tenant_id=eq.123" で特定テナントの変更だけを受信。
Supabase Realtime と Pusher/Firebase の比較は?
Supabase の利点は Postgres Changes が直接データベースを監視できること、RLS が自動適用されること。欠点は学習曲線がやや急なこと。すでに Supabase Auth/Storage を使っているなら Realtime もスムーズに追加可能。単純な WebSocket だけなら Pusher の方がすぐ始められる。

7 min read · 公開日: 2026年4月26日 · 更新日: 2026年4月29日

関連記事

コメント

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