【WebSocket設計】再接続エラーを回避する再マウント遅延の方法とは
切断が検知されてから、わずか数百ミリ秒後に再接続が完了する。ところが画面に表示されているエリア名は「取得失敗」のまま動かない。ユーザーが今どの場所にいるのか分からなくなる、地味だが致命的な不具合でした。
前提知識・システム構成
本記事の対象は、ブラウザ上で動作する対象サービスのクライアントです。ユーザーの端末とサーバーはWebSocketで常時接続され、ルーム内の位置情報やチャット、エリア情報などをやり取りしています。WebSocketはTCP上に構築された双方向通信プロトコルで、ネットワークの瞬断やサーバー側の再起動によって接続が切れることがあります。クライアント側の実装では、切断を検知すると一定間隔で再接続を試み、成功すればアプリケーション状態を復元する処理を走らせるのが一般的な設計です。
本文中で使う「502エラー」は、サーバー側のプロキシがバックエンドから正常な応答を受け取れなかった際に返すHTTPステータスです。デプロイ直後やインスタンスの再起動直後など、サーバー側が一時的に不安定な状態にある局面で発生しやすい特徴があります。もうひとつ、「AudioContext」はWeb Audio APIの中核となるオブジェクトで、音声処理グラフの生成・破棄を担います[1]。そして本記事で「再マウント」と呼ぶのは、UIコンポーネントの再初期化処理全般を指します。React的な仮想DOMの再マウントに限らず、状態を破棄して作り直す処理全体を含む用語として使っています。
再接続直後に何が起きていたか
自動復帰の実装自体は動いていました。切断を検知し、指数バックオフで再接続を試み、WebSocketのopenイベントを受け取った時点でUIの再構築処理を呼び出す。ここまでは典型的な設計です。問題は「再構築処理を呼び出すタイミング」にありました。
再接続が完了した直後、サーバー側のエリア情報取得APIに一時的な502エラーが返ることがありました。ちょうどサーバー側のインスタンスが復帰しきっていない瞬間に問い合わせが飛んでしまう格好です。このエラーレスポンスをそのままUIに反映していたため、エリア名の表示が「取得失敗」に固定され、その後サーバーが安定してもリトライが働かず表示が更新されないままになっていました。ユーザーから見れば、通信自体は復旧しているのに自分の居場所が分からない状態が続くことになります。
もうひとつの症状はサウンド周りです。再マウント処理の中でAudioContextを作り直していたため、切断復帰のたびに音声処理グラフが再構築され、そのタイミングでノイズが発生していました。復帰直後の体験としては、視覚情報の不整合と聴覚的な違和感が重なる形になっていました。
2つの不具合を貫くタイミング競合
2つの症状は別々の不具合に見えますが、根っこは同じです。再接続完了のイベントと、サーバー側インスタンスの実際の準備完了、そしてクライアント側のUI復帰処理という3つのタイミングが揃っていなかったことが原因でした。WebSocketの接続確立はネットワークレベルの疎通を示すだけで、アプリケーションが必要とするAPI群がすべて正常に応答できる状態になったことまでは保証しません。
この前提を踏まえずに「WebSocketが繋がった瞬間=サーバーは準備完了」とみなしてUI復帰処理を即座に走らせてしまうと、以下のような競合状態(race condition)が発生します。
データ取得要求がサーバーの不安定期にぶつかると、エラーレスポンスがそのままUI状態として保存されます。エラー発生後に自動リトライを組み込んでいない限り、サーバーが復帰しても表示は更新されません。今回のケースもリトライ処理自体は存在せず、初回の応答をそのまま状態に反映する実装だったため、固定表示という形で症状が現れていました。
「すぐ復帰すれば安全」という判断がなぜ崩れたか
最初にこの設計を組んだ際は、再接続が完了した時点でUIを復元するのが最も安全だと考えていました。通信が切れている間はユーザーに古い情報を見せ続けるほうが問題だという判断です。この考え方自体は間違っていません。問題は、WebSocketの接続確立という「プロトコルレベルの復帰」と、バックエンドAPI群が正常応答を返せる「アプリケーションレベルの復帰」を同一視してしまった点にありました。
WebSocket自体の再接続は、TCPコネクションが確立しハンドシェイクが完了すればopenイベントが発火します。しかしその裏側でロードバランサーやアプリケーションサーバーのインスタンスが再起動中であれば、HTTP APIへのリクエストは502を返し続けます。プロトコルの復帰とアプリケーションの復帰の間には、環境によって変動する時間差が存在する、という前提を見落としていたことが、素朴な即時復帰実装の失敗理由でした。
再マウントを遅延させるという設計判断
そこで採用したのが、再接続完了イベントを受け取った直後にUI再マウントを実行せず、一定時間待ってから実行する設計です。狙いは単純で、サーバー側の不安定期をやり過ごしてからデータ取得とUI再構築を行うことにあります。
この遅延は副次的にもう一つの問題も解決しました。AudioContextの再生成です。遅延区間を設けたことで、既存のAudioContextを即座に破棄せず、必要であれば`suspend()`で一時停止し`resume()`で再開する猶予が生まれます[2]。Web Audio APIの仕様では、`suspend()`はシステムリソースを解放し、`resume()`でそれらを再取得してレンダリングを再開すると定義されており[1]、破棄と再生成を繰り返すよりもリソース操作の単位を減らせる余地があります。今回はこの余裕を使い、再マウント直後に発生していたノイズの発生源となるコンテキスト再生成のタイミングをずらす形で対処しました。
📦 旧状態(Before)
- 再接続完了イベントを受けて即座にUI再マウントを実行
- エリア情報APIの502応答をそのまま状態に反映
- 再マウントのたびにAudioContextを都度再生成
✅ 新状態(After)
- 再接続完了後、一定時間待機してから再マウントを実行
- 待機区間中にサーバー側の不安定期が収束
- AudioContextの再生成タイミングを分離しノイズを回避
接続管理とタイマー制御の実装
実装の骨格は、接続状態を管理するクラスに遅延タイマーを組み込む形です。WebSocketのopenイベントを受け取った時点で即座にコールバックを呼ぶのではなく、タイマーをセットし、その完了をもって再マウント処理を呼び出します。以下は今回の設計思想を一般化したパターンです(社内固有の実装名・変数名は一般名に置き換えています)。
class ConnectionManager {
constructor(remountCallback, delayMs = 1500) {
this.remountCallback = remountCallback;
this.delayMs = delayMs;
this.remountTimer = null;
}
onReconnected() {
// 再接続完了を即座にUI復帰へつなげず、
// サーバー側の安定化を待つための遅延を挟む
if (this.remountTimer) {
clearTimeout(this.remountTimer);
}
this.remountTimer = setTimeout(() => {
this.remountCallback();
}, this.delayMs);
}
onDisconnected() {
// 切断時は保留中の再マウントをキャンセルする
if (this.remountTimer) {
clearTimeout(this.remountTimer);
this.remountTimer = null;
}
}
}
ポイントは、再接続イベントのたびにタイマーをリセットしていることです。短時間で切断と再接続を繰り返すような不安定なネットワーク下では、遅延中に再度切断が起きるケースも想定し、その場合は保留中の再マウントを取り消して二重実行を防いでいます。エラーハンドリング側では、遅延後に呼び出すデータ取得処理に対しても通常のリトライ・エラー表示ロジックを残し、遅延だけに依存しない構成にしています。
社内検証版での確認結果
この変更は社内検証版のリリースに含める形で反映しました。具体的な発生回数や遅延時間の計測値までは記録していませんが、確認できたのは次の2点です。502エラーが発生する局面でもエリア名が「取得失敗」のまま固定される事象が解消されたこと。そして再マウント時に発生していたAudioContext再生成起因のノイズが確認されなくなったことです。定量的な検証にはまだ至っていませんが、症状そのものが再現しなくなったことは、実際に触って確認しています。
担当エンジニアより:
「切断からの自動復帰まわりを改善(再接続完了後に再マウントを遅延/一時502でエリア名が「取得失敗」固定になる不具合を解消/再マウント時のAudioContextノイズを解消)」
遅延時間をどう決めるか
遅延を入れる設計を採用した際、次に出てくる疑問は「何秒待てば十分か」です。考え方としては大きく2つあります。ひとつは固定時間による遅延で、経験的に妥当と思われる秒数を設定し、それを待ってから再マウントする方式です。実装がシンプルで、遅延処理自体にサーバー側の状態を問い合わせる必要がありません。もうひとつは動的遅延で、サーバー側の準備完了をヘルスチェックなどで検知してから再マウントする方式です。前者より正確ですが、検知用のエンドポイントや追加のポーリング処理が必要になり実装コストが上がります。
今回はまず固定時間による遅延を採用し、症状の解消を優先しました。ネットワーク環境やサーバーの復旧速度は状況によって変わるため、固定値がすべての環境で十分な余裕を持つとは限りません。将来的にサーバー側の準備完了をより確実に検知する仕組みへ発展させる余地は残っています。
現状の制約・未対応項目
今回の対応には限界も残っています。遅延時間を固定値で持たせているため、ネットワーク状況やサーバーの復旧にかかる時間が想定より長い環境では、遅延区間内にサーバーの不安定期が収まりきらない可能性があります。逆に安定した環境では、必要以上に復帰を待たせている場合もあり得ます。動的にサーバーの準備完了を検知する仕組みは、今回のスコープでは未対応です。また、遅延中に発生する複数回の切断・再接続に対する挙動は、タイマーのリセットで対処していますが、極端に不安定な回線環境での網羅的な検証までは行えていません。
この設計が対応していない競合状態
WebSocket周辺では、今回扱った以外にも競合状態が起こり得ます。たとえばメッセージの到達順序が保証されないケースや、同一ユーザーが複数タブで同時に接続した場合の状態不整合、モバイル端末でアプリがバックグラウンドから復帰した際のセッション状態のズレなどです。これらは再マウントのタイミング調整だけでは解決しない、それぞれ別の設計対応が必要な問題です。今回の遅延実行パターンは、あくまで「再接続完了直後のサーバー不安定期とUI復帰処理の競合」という限定的な範囲に対する対策であり、WebSocket復帰処理全般の競合状態を網羅的に解消するものではない、という点は正直に書いておきたいところです。
まとめ
今回の不具合の原因は、再接続完了というプロトコルレベルの復帰と、サーバー側APIが実際に安定して応答できる状態との間に生じる時間差を、クライアント側が考慮していなかったことにありました。その時間差の中でUI再マウント処理が走ってしまい、502エラーのレスポンスがそのまま状態に固定される問題と、AudioContextの再生成起因のノイズという2つの症状が同時に現れていたわけです。
解決策として採った遅延実行パターンは、この競合の窓を物理的に広げ、サーバー側の安定化とクライアント側のリソース準備の両方に猶予を持たせる方針でした。特別な仕組みを新設したわけではなく、再マウントのタイミングを一段遅らせるだけの変更ですが、切断復帰という一見単純なフローにも細かなタイミング調整が必要になる、という気づきを得た改善でした。同じような競合に心当たりがある方の参考になれば幸いです。


























