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

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 ChangesPostgreSQL データベースチャットメッセージ、注文ステータスの変更中程度

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

Broadcast は「伝言ゲーム」のようなもの。あなたが一言叫ぶと、聞いている全員が聞こえるが、言い終わると消えて痕跡は残らない。「瞬間的」なデータに適している——例えばコラボレーション編集時のカーソル位置。マウスを動かすと他人のカーソルがついてくるが、5秒前にカーソルがどこにあったかは誰も気にしない。

Presence は「出席簿」のようなもの。各人が入ってきて出席を取り、自分の状態(オンライン、オフライン、編集中…)を書くと、全員がそのリストを見られる。重要なのは:状態が自動的に同期され、CRDT(Conflict-free Replicated Data Types)に基づいているため、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 が自動でやってくれる。本当に気にすべきなのは、タイムアウト後の再接続戦略だ。

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

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

• 最初は素早く再接続(1秒)
• 後は徐々に遅く(3秒)
• 再接続成功後、見逃したメッセージを即座に同期
heartbeatCallback とは? どんな時に使う?
heartbeatCallback は2026年に追加されたパラメータで、ハートビート状態を能動的に監視できます。SDK のデフォルトメカニズムより早く切断を検出できる——テストでは切断検出時間が45秒から12秒に短縮。リアルタイム性が高いアプリ(オンラインコラボレーション)で有用。
worker: true は何を解決する?
ブラウザのバックグラウンドタブ throttling による切断問題を解決。有効化すると、WebSocket 接続が Web Worker で実行され、ブラウザの制限の影響を受けない。実測でバックグラウンド5分間実行時、ハートビート成功率が63%から96%以上に向上。コラボレーションアプリやカスタマーサポートシステムに適している。
Realtime 購読は RLS ルールに従う?
はい、Realtime 購読も Row Level Security ルールに従います。ユーザーは権限のある変更だけを受け取り、セキュリティロジックを二重に書く必要はありません。
本番環境で注意すべき設定パラメータは?
3つの主要なパラメータ:

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

8 min read · 公開日: 2026年5月12日 · 更新日: 2026年5月13日

関連記事

コメント

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