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

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

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

Broadcast は「伝声筒」のようなものだ。叫べば聞いている全員に届く。しかし言い終われば消え、痕跡は残らない。「一瞬だけ意味がある」データ向き——協調編集のカーソル位置など。マウスを動かせば他人のカーソルも動く。5秒前にどこにあったかは誰も気にしない。

Presence は「出席簿」だ。入場して自分の状態(オンライン、オフライン、編集中……)を書くと、全員がそのリストを見られる。状態は自動同期され、CRDT(Conflict-free Replicated Data Types)ベースなので、2人が同時に同じ行を触っても競合しない。

Postgres Changes は「データベースリスナー」だ。DB のデータが変われば通知が来る。最も「重い」が最も信頼できる。データは PostgreSQL に残るので、切断して再接続してもメッセージは失われない。

どう選ぶ? シンプルな判断法

2つの質問を自分に投げかけよう。

  1. データを永続化する必要があるか?

    • 必要 → Postgres Changes
    • 不要 → 次の質問へ
  2. データは「イベント」か「状態」か?

    • イベント(何かが起きた)→ 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.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年の新機能)

ハートビートは自動だが、問題がある。接続は生きているように見えるのに、実際にはメッセージが届かないことがある。

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_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 + 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つの機能の違いは?
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 の方がすぐ始められます。

8分で読めます · 公開日: 2026年5月12日 · 更新日: 2026年6月15日

関連記事

コメント

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