テックブログ

WebSocketの再接続設計:切断後に安全に復旧する方法

WebSocketの再接続設計:切断後に安全に復旧する方法

WebSocketの再接続設計とは、切断された通信を単にもう一度つなぐだけではなく、安全な間隔で接続を復旧し、その後に認証・購読・画面データなどのアプリケーション状態まで元に戻すための設計です。WebSocketはネットワーク変化、端末のスリープ、Wi-Fiとモバイル回線の切り替え、サーバー再起動などで切断されるため、実運用では再接続を前提に考える必要があります。切断直後に無制限で接続を繰り返すのではなく、指数バックオフやジッターで負荷を抑え、再接続後には切断中に失われたデータを補完する仕組みも検討することが重要です。

1. WebSocketはなぜ切断されるのか

1-1. ネットワークは常に安定しているとは限らない

WebSocketは接続を長時間維持できる通信方式ですが、接続が永久に維持されるわけではありません。実運用では「いつか切れるもの」と考えて設計する必要があります。

WebSocketでは、一度接続するとクライアントとサーバーの双方からメッセージを送信できます。しかし、その接続は端末からサーバーまでのネットワーク経路に依存しています。Wi-Fiの電波が弱くなる、ルーターが再起動する、通信キャリア側で経路が変化するなど、アプリケーションから制御できない要因でも接続は失われます。

開発用PCとローカルサーバーだけで試していると切断を経験しにくいため、本番でも同じように安定すると考えてしまいがちです。モバイル端末や不安定なネットワークを利用するサービスでは、正常系だけでなく切断と復旧を通常の動作として確認することが重要です。

1-2. スリープや回線切替でも接続は失われる

端末のスリープやネットワーク切り替えも、WebSocketが切断される代表的な原因です。利用者が明示的に通信を切断しなくても、接続状態は変化します。

例えば、ノートPCを閉じてスリープさせたり、スマートフォンがWi-Fiからモバイル回線へ切り替わったりすると、接続時に使用していたネットワーク経路がそのまま利用できなくなることがあります。ブラウザが復帰した時点ではJavaScriptの画面状態が残っていても、WebSocket接続だけは利用できなくなっている場合があります。

チャット画面を開いたまま電車で移動する、スマートフォンの画面を消して数分後に戻る、といった操作は珍しくありません。そのため、openイベントが一度発生したことだけをもって「以後も接続済み」と判断せず、現在の接続状態を継続的に管理する必要があります。

1-3. サーバー側の再起動や障害でも切断される

クライアント側に問題がなくても、WebSocketサーバーの再起動や障害によって接続は切断されます。アプリケーションのデプロイも、既存接続が終了する原因になり得ます。

WebSocketは長時間接続を維持するため、サーバープロセスが終了すると、そのプロセスが保持していた接続も終了します。ロードバランサーやリバースプロキシの設定変更、アイドルタイムアウト、コンテナの入れ替えなどでも接続状態が変わる可能性があります。

本番環境では「サーバーを一度も再起動しない」運用は現実的ではありません。安全な再接続設計があれば、デプロイや一時障害によって接続が切れても、利用者に再読み込みを要求せず復旧できる可能性が高まります。

2. WebSocket再接続の基本

2-1. closeイベントを起点に再接続する

ブラウザ標準のWebSocket APIで再接続を実装するときは、基本的にcloseイベントを起点として新しい接続を作ります。標準WebSocket APIそのものには、自動再接続機能は含まれていません。

接続が終了すると、WebSocketオブジェクトではcloseイベントが発生します。そこで新しいWebSocketインスタンスを作成すれば、接続を再度試みることができます。なお、errorイベントとcloseイベントの両方から再接続を開始すると、同じ障害で再接続処理が二重に走る可能性があるため注意が必要です。

例えば、errorではログや画面表示だけを行い、実際の再接続はcloseに集約すると処理を整理しやすくなります。ただし、利用者が明示的にログアウトした場合や画面を閉じた場合は再接続してはいけないため、「再接続すべき切断か」を判断する状態も必要です。

2-2. 再接続中の状態を管理する

再接続処理では、「接続済み」「接続中」「再接続待ち」「停止済み」といった状態を管理することが重要です。単純にcloseのたびにconnect()を呼ぶだけでは、二重接続や不要な再試行が起こりやすくなります。

WebSocketにはreadyStateがあり、CONNECTINGOPENCLOSINGCLOSEDを確認できます。ただし、アプリケーションとして「利用者が再接続を許可しているか」「再試行タイマーが予約済みか」といった情報は別途管理する必要があります。

例えばログアウト後は、WebSocketがCLOSEDであっても再接続してはいけません。反対に、一時的な回線切断なら再接続を試みます。接続状態とアプリケーション状態を分けて持つと、処理を整理しやすくなります。

2-3. 二重接続を防ぐ

再接続設計では、同じ画面から複数のWebSocket接続が同時に作られないようにする必要があります。二重接続が発生すると、同じメッセージを複数回受信するなどの問題につながります。

原因になりやすいのは、複数箇所からconnect()を呼ぶことです。例えばcloseイベント、オンライン復帰イベント、画面再表示イベントから、それぞれ独立して再接続すると、ほぼ同時に複数の接続処理が開始される可能性があります。

接続を開始する前に、既存ソケットがCONNECTINGまたはOPENではないか確認し、再接続タイマーがすでに存在する場合は新しいタイマーを追加しないようにします。接続処理を1つの関数へ集約することも有効です。

3. 即時再接続を繰り返してはいけない理由

3-1. 障害時に大量の再接続が集中する

WebSocketが切断された直後に、すべてのクライアントが即座に再接続し続ける設計は避けるべきです。サーバー障害時に再接続リクエストが集中し、復旧をさらに難しくする可能性があります。

例えばサーバーの再起動によって数千の接続が同時に失われた場合、すべてのブラウザが同じタイミングで再接続を始めると、サーバーが起動した瞬間に大量の接続処理が発生します。接続できなければ、さらに同じタイミングで次の試行が行われます。

このような状況を避けるには、再試行まで少し待ち、失敗が続くほど間隔を広げます。さらにクライアントごとに待機時間をずらすと、再接続の集中を分散できます。

3-2. 指数バックオフで再試行間隔を広げる

指数バックオフは、接続失敗が続くほど次の再試行までの待機時間を長くする方法です。障害中のサーバーへ短い間隔で接続要求を送り続けることを防げます。

基本的な考え方は、試行回数に応じて待機時間を指数的に増やすことです。例えば概念的には、次のように表せます。

delay = min(maxDelay, baseDelay × 2^attempt)

baseDelayは最初の基準時間、attemptは連続失敗回数、maxDelayは待機時間の上限です。実際の値はサービスの即時性、利用者数、サーバー構成などによって決める必要があり、特定の秒数がすべてのシステムに適しているわけではありません。

指数バックオフを利用すると、短時間の一時切断では比較的早く復旧を試し、長時間の障害では接続要求を徐々に減らせます。接続成功時には失敗回数をリセットし、次回の切断では再び初期状態から開始します。

3-3. ジッターでクライアントの再試行を分散する

ジッターは、再試行までの待機時間へランダムな揺らぎを加え、多数のクライアントが同じタイミングで再接続することを防ぐ考え方です。バックオフと組み合わせて使われます。

すべてのクライアントが同じ計算式と同じ初期時間を使うと、切断時刻が同じ場合、次回再試行時刻も揃ってしまいます。バックオフだけでは、再接続の波が周期的に発生する可能性があります。

例えば概念的には、次のようにランダム値を加えます。

base = min(maxDelay, baseDelay × 2^attempt)
delay = random(0, base)

このように待機時間をクライアントごとにばらつかせることで、再接続要求を時間方向に分散できます。ジッターにも複数の方式があるため、実際には利用しているシステムやライブラリの設計方針に合わせて選択します。

4. 再接続処理をJavaScriptで実装する

4-1. 最小構成の再接続処理

最初に、WebSocketが閉じたら一定時間後に接続し直す最小構成を確認します。仕組みを理解するには分かりやすい一方、そのまま本番利用するには不十分です。

次のコードでは、接続成功時にopen、メッセージ受信時にmessage、切断時にcloseを処理しています。切断するとsetTimeout()で再接続を予約します。

const WS_URL = "wss://example.com/ws";

let socket = null;
let reconnectTimer = null;
let shouldReconnect = true;

function connect() {
  socket = new WebSocket(WS_URL);

  socket.addEventListener("open", () => {
    console.log("WebSocketに接続しました");
  });

  socket.addEventListener("message", (event) => {
    console.log("受信:", event.data);
  });

  socket.addEventListener("error", (error) => {
    console.error("WebSocketエラー:", error);
  });

  socket.addEventListener("close", () => {
    console.log("WebSocketが切断されました");

    if (!shouldReconnect) {
      return;
    }

    reconnectTimer = setTimeout(() => {
      console.log("WebSocketへの再接続を試みます");
      connect();
    }, 3000);
  });
}

function disconnect() {
  shouldReconnect = false;

  if (reconnectTimer !== null) {
    clearTimeout(reconnectTimer);
    reconnectTimer = null;
  }

  if (socket !== null) {
    socket.close();
  }
}

connect();

正常接続の流れ: connect()WebSocketを生成し、接続が確立するとopenイベントが発生します。その後、サーバーから届いたデータをmessageで受信します。

切断から再試行の流れ: 接続が終了するとcloseが発生し、shouldReconnecttrueであればタイマーを設定します。タイマー満了後にconnect()を再実行し、接続できれば再びopenが発生します。

問題点: この例では再試行間隔が固定です。サーバーが長時間停止していても同じ頻度で再接続し続けます。また、再試行回数やジッター、二重接続防止、認証更新、購読復元もありません。3秒という値も説明用であり、絶対的な推奨値ではありません。

停止方法: 利用者のログアウトや画面破棄時にはdisconnect()を呼び、再接続タイマーを解除したうえでソケットを閉じます。意図的な切断と障害による切断を区別することが重要です。

4-2. バックオフを組み込む

本番を意識した再接続では、連続失敗回数に応じて待機時間を広げるバックオフを組み込みます。さらにジッターを加えると、多数クライアントの再試行時刻を分散できます。

次のコードでは、連続失敗回数から最大待機時間を計算し、その範囲内からランダムな待機時間を選びます。また、タイマーがすでに存在する場合は新たな再接続を予約しません。

const WS_URL = "wss://example.com/ws";

const BASE_DELAY_MS = 1000;
const MAX_DELAY_MS = 30000;

let socket = null;
let reconnectTimer = null;
let reconnectAttempt = 0;
let shouldReconnect = true;

function calculateReconnectDelay() {
  const exponentialDelay = Math.min(
    MAX_DELAY_MS,
    BASE_DELAY_MS * (2 ** reconnectAttempt)
  );

  // 0〜exponentialDelayの範囲でランダム化します
  return Math.random() * exponentialDelay;
}

function scheduleReconnect() {
  if (!shouldReconnect) {
    return;
  }

  // すでに再接続待ちなら二重予約しません
  if (reconnectTimer !== null) {
    return;
  }

  const delay = calculateReconnectDelay();

  console.log(
    `再接続を予約します。試行回数=${reconnectAttempt}, 待機=${Math.round(delay)}ms`
  );

  reconnectTimer = setTimeout(() => {
    reconnectTimer = null;
    reconnectAttempt += 1;
    connect();
  }, delay);
}

function connect() {
  if (
    socket !== null &&
    (
      socket.readyState === WebSocket.CONNECTING ||
      socket.readyState === WebSocket.OPEN
    )
  ) {
    return;
  }

  console.log("WebSocketへの接続を開始します");

  socket = new WebSocket(WS_URL);

  socket.addEventListener("open", () => {
    console.log("WebSocketへの接続に成功しました");

    reconnectAttempt = 0;
  });

  socket.addEventListener("message", (event) => {
    console.log("受信:", event.data);
  });

  socket.addEventListener("error", (error) => {
    // 再接続自体はcloseイベント側で行います
    console.error("WebSocketエラー:", error);
  });

  socket.addEventListener("close", (event) => {
    console.log(
      `WebSocketが切断されました code=${event.code}`
    );

    socket = null;

    scheduleReconnect();
  });
}

function disconnect() {
  shouldReconnect = false;

  if (reconnectTimer !== null) {
    clearTimeout(reconnectTimer);
    reconnectTimer = null;
  }

  if (socket !== null) {
    socket.close();
    socket = null;
  }
}

connect();

正常接続の流れ: connect()が接続を開始し、openが発生すると接続成功です。成功した時点でreconnectAttemptを0へ戻します。

切断から再試行の流れ: closeが発生するとscheduleReconnect()が呼ばれ、失敗回数をもとにバックオフ時間を計算します。さらにランダム化した待機時間を使い、再接続要求が集中しにくくしています。

再接続成功の流れ: タイマー後に再びconnect()が実行され、接続できればopenが発生します。ここで失敗回数をリセットし、通常状態へ戻します。

注意点: BASE_DELAY_MSMAX_DELAY_MSは説明用の値です。実際の再試行間隔はサービスの要件に合わせて決めてください。また、この時点では「通信接続」が戻っただけで、購読やデータの復元はまだ完了していません。

4-3. 成功時に再試行状態をリセットする

再接続に成功したら、バックオフ用の失敗回数や再接続待ち状態をリセットします。リセットしないと、次回の一時切断でも長い待機時間から再開してしまいます。

例えば5回失敗した後に接続が回復した場合、その接続が長時間正常に動作した後の切断は、新しい障害として扱うのが自然です。過去の失敗回数を引き継ぐ必要はありません。

ただし、TCP/WebSocket接続が確立しただけで即座に完全復旧と判断してよいとは限りません。認証確認や初期同期が必要なアプリケーションでは、それらが成功した時点を「アプリケーションとしての復旧成功」と扱う設計も考えられます。

5. 再接続後に復元すべき状態

5-1. 認証状態を確認する

再接続時には、認証状態が現在も有効か確認する必要があります。最初の接続時に有効だった認証情報が、切断中も有効であるとは限りません。

例えばセッションCookieには有効期限があり、アクセストークンも失効する場合があります。WebSocket接続を再生成した時点で認証が切れているなら、サーバーは接続を拒否したり、接続後に認証エラーを返したりする可能性があります。

ブラウザ標準のWebSocketコンストラクターでは、fetch()のように任意のAuthorizationヘッダーを自由に設定することはできません。そのため、Cookieベースの認証、接続用トークン、サブプロトコルなど、システム全体の認証設計と合わせて検討する必要があります。

再接続が認証エラーで失敗している場合は、バックオフを続けるだけでは解決しません。通常のHTTP APIで認証情報を更新する、再ログインを促すなど、エラーの種類に応じた処理が必要です。

5-2. チャンネルやルームを再購読する

WebSocketでチャンネルやルームを購読している場合、再接続後に購読状態を復元する必要があります。新しいWebSocket接続は、以前の接続とは別の接続だからです。

例えばチャット画面でroom-123を購読していたとしても、接続が切れるとサーバー側の購読情報も失われる設計が一般的です。再接続できただけでは、そのルームのメッセージを再び受け取れるとは限りません。

クライアント側に現在の購読対象を保持し、openイベント後に再購読メッセージを送る方法があります。

const subscriptions = new Set([
  "room-123",
  "notifications"
]);

function restoreSubscriptions(socket) {
  for (const channel of subscriptions) {
    socket.send(
      JSON.stringify({
        type: "subscribe",
        channel
      })
    );
  }
}

function handleOpen() {
  console.log("WebSocket接続が復旧しました");

  restoreSubscriptions(socket);
}

コードの役割: クライアントが保持している購読一覧を使い、新しいWebSocket接続上で再度subscribeメッセージを送ります。

実行時の注意点: サーバー側で同じチャンネルを二重購読しても問題が起きないようにするか、購読IDを使って重複を防ぐ設計が必要です。また、認証処理が完了する前に購読メッセージを送ると拒否される場合があるため、復元順序も重要です。

再接続後の処理を、例えば「接続成功 → 認証確認 → 購読復元 → 最新状態取得 → 通常受信開始」という順番で整理すると、復旧処理を追いやすくなります。

5-3. 切断中に失ったデータを補完する

WebSocketに再接続できても、切断中に発生したメッセージが自動的に再送されるわけではありません。通信接続の復旧と、データ状態の復旧は別の問題です。

例えばチャットで12:00:00に接続が切れ、12:00:10に再接続した場合、その10秒間に届いたメッセージをWebSocket標準が自動保存してくれるわけではありません。サーバー側に履歴がなければ、クライアントはそのメッセージを失います。

対策として、各イベントに連番やイベントIDを付け、クライアント側で「最後に受け取ったID」を保持します。再接続後にHTTP APIやWebSocketメッセージを使って、そのID以降のイベントを取得する設計が考えられます。

let lastEventId = null;

function handleMessage(event) {
  const message = JSON.parse(event.data);

  if (message.eventId !== undefined) {
    lastEventId = message.eventId;
  }

  renderMessage(message);
}

async function resyncMissedEvents() {
  if (lastEventId === null) {
    return;
  }

  const response = await fetch(
    `/api/events?after=${encodeURIComponent(lastEventId)}`
  );

  if (!response.ok) {
    throw new Error("差分データの取得に失敗しました");
  }

  const events = await response.json();

  for (const event of events) {
    renderMessage(event);
    lastEventId = event.eventId;
  }
}

コードの役割: 最後に正常受信したイベントIDを記録し、再接続後にHTTP APIから未受信イベントを取得します。

実行時の注意点: イベントIDの採番、履歴保持期間、重複イベントの扱いはサーバー側の設計が必要です。再接続処理をクライアントだけ実装しても、欠落データを復元できる仕組みがサーバーになければ完全な復旧はできません。

また、差分取得中にWebSocketから新しいイベントが届く可能性もあります。必要に応じてイベントIDで重複を除外し、順序を確認してから画面へ反映する設計が重要です。

6. 再接続設計でよくある落とし穴

6-1. 複数の再接続タイマーが同時に動く

再接続処理でよくある問題の1つが、複数の再接続タイマーを同時に作ってしまうことです。結果として複数のWebSocket接続が作られる可能性があります。

例えばerrorcloseの両方でscheduleReconnect()を呼び、さらにonlineイベントでも接続を開始すると、同じタイミングで複数の処理が走ることがあります。

再接続処理はできるだけ1つの経路へ集約し、reconnectTimer !== nullなら追加予約しない、readyStateCONNECTINGまたはOPENなら新規接続しない、といったガードを入れます。

二重接続は見つけにくい不具合です。画面上では正常に見えても、通知が2回表示される、同じ購読が複数作成される、サーバーの同時接続数が増えるなどの形で現れます。

6-2. 永久に再試行し続ける

自動再接続を実装しても、あらゆるエラーで永久に再試行し続けるべきとは限りません。待っても解決しないエラーがあるためです。

ネットワークの一時切断やサーバー再起動なら、時間を置いて再試行する意味があります。一方、認証失敗、利用停止、接続先URLの誤り、サーバーが明示的に再接続不可と判断した状態では、同じ接続を繰り返しても成功しない可能性があります。

再試行回数の上限、一定時間経過後の停止、利用者による「再接続」ボタン、オンライン状態の変化を待ってから再開する処理などを、サービス要件に応じて検討します。

重要なのは「何回が正解か」ではなく、どのエラーなら再試行によって回復する可能性があるかを整理することです。サーバー側のClose Codeやアプリケーション固有のエラー情報も判断材料になります。

6-3. 接続復旧だけでデータも復旧したと考える

WebSocketのopenイベントが再び発生しても、アプリケーション全体が元通りになったとは限りません。再接続は復旧処理の入口です。

接続が切れている間に、新しいチャットメッセージ、注文状態の変更、共同編集データなどが発生している可能性があります。また、以前購読していたルーム情報も、新しい接続には引き継がれていないかもしれません。

復旧時には、少なくとも「認証」「購読」「未受信データ」「現在の画面状態」を確認します。システムによっては、差分取得より現在状態をHTTP APIから丸ごと再取得するほうが簡単で安全なこともあります。

再接続後に何を復元するかは、WebSocketの仕様だけでは決められません。アプリケーションがどのデータを正として扱うかを定義し、サーバーとクライアントの両方で復旧方法を設計する必要があります。

7. まとめ

7-1. WebSocketは切断を前提に設計する

WebSocketは長時間接続できる通信方式ですが、ネットワーク、端末、サーバー、プロキシなどの影響によって切断されます。そのため、本番環境では切断を例外ではなく通常起こり得る状態として扱います。

再接続するときは、切断直後に無制限で接続を繰り返さず、指数バックオフで再試行間隔を広げ、ジッターで多数クライアントの再接続タイミングを分散します。また、複数タイマーや二重接続を防ぐ状態管理も必要です。

そして、WebSocket接続が再びOPENになったことだけで復旧完了とは考えません。認証状態の確認、チャンネルの再購読、切断中に失ったデータの補完まで完了して、初めてアプリケーションとして安全に復旧したと判断できます。

7-2. 再接続実装のチェックリスト

WebSocketの再接続設計では、「もう一度つなぐ処理」と「元のアプリケーション状態へ戻す処理」を分けて確認すると整理しやすくなります。特に開発環境では切断が少ないため、意図的にネットワークを切る、サーバーを再起動するなどのテストも重要です。

再試行方法については、利用者数やサービス要件によって適切な設定が変わります。固定の秒数や回数を絶対的な正解として採用せず、本番相当の環境で確認してください。

実装時には、次の項目を確認します。

  • WebSocketが切断されることを前提にしているか
  • 再接続処理をcloseなど1つの経路へ整理しているか
  • errorcloseの両方から二重再接続していないか
  • 再接続タイマーが複数作成されないか
  • CONNECTINGOPEN中に二重接続しないか
  • 意図的なログアウトや画面終了時は再接続を停止できるか
  • 失敗が続くときはバックオフしているか
  • 多数クライアントの再接続をジッターで分散しているか
  • 再試行を止める条件を決めているか
  • 接続成功時にバックオフ状態をリセットしているか
  • 再接続時に認証情報が有効か確認しているか
  • チャンネルやルームを再購読しているか
  • 最後に受信したイベント位置を管理しているか
  • 切断中に失ったデータを補完できるか
  • 重複イベントを安全に処理できるか
  • クライアントだけでなくサーバー側にも復旧処理があるか
  • サーバー再起動やネットワーク切断を含むテストをしているか

WebSocket再接続の目的は、単に緑色の「接続済み」表示へ戻すことではありません。通信障害が起きてもデータの欠落や二重処理をできるだけ避け、利用者が継続してサービスを利用できる状態へ安全に戻すことが重要です。

8. 参考リンク