テックブログ

WebSocketのheartbeat入門:ping/pongで切断を検知する

WebSocketのheartbeat入門:ping/pongで切断を検知する

WebSocketのheartbeatとは、接続が実際に通信可能な状態かを確認するため、一定間隔で信号を送り、その応答を確認する生存確認の仕組みです。WebSocketプロトコルにはPing/Pongという制御フレームが定義されていますが、ブラウザ標準のWebSocket APIではJavaScriptからこれらの制御フレームを直接送受信できません。そのためブラウザアプリでは、JSONや文字列による独自のheartbeatメッセージを送信し、応答が一定時間得られなければ異常と判断して接続を閉じ、再接続処理へ移す設計がよく使われます。heartbeatは「接続を絶対に切れなくする仕組み」ではなく、「通信不能を検知する仕組み」と考えることが重要です。

1. WebSocketで切断検知が必要な理由

1-1. 接続中に見えても通信不能になることがある

WebSocketオブジェクトのreadyStateOPENであっても、その瞬間に相手と確実に通信できることまで保証しているわけではありません。ネットワークの異常がまだブラウザに検知されていない時間が存在するためです。

例えば、WebSocketで接続した後にWi-Fiルーターの電源が突然切れたとします。ブラウザは物理的な通信障害を即座にWebSocketの切断として認識できるとは限りません。その間、JavaScriptから見るとソケットがOPENのまま残っている場合があります。

そのため、「socket.readyState === WebSocket.OPENなら正常」とだけ判断するのは十分ではありません。重要なリアルタイム機能では、一定時間内に相手から応答が得られているかという観点も含めて接続状態を判断します。

1-2. ネットワーク断をすぐ検知できない場合がある

ネットワークが切れたからといって、必ずその瞬間にcloseイベントが発生するわけではありません。通信経路によっては、次にデータを送ろうとしたときや内部的なタイムアウトが発生したときに初めて異常が分かることがあります。

WebSocketの下ではTCP接続が利用されていますが、アプリケーションが何も送受信していない時間が長いと、通信経路が失われたことをすぐ確認できない場合があります。また、途中のルーター、NAT、プロキシ、ロードバランサーなどが接続状態を保持していることもあります。

例えば通知画面で10分間何もイベントが発生しなければ、正常だから静かなのか、途中で通信不能になったのかを画面側から区別しにくくなります。このような状態を定期的な生存確認によって判定するのがheartbeatの役割です。

1-3. 異常な接続を残すと何が起きるか

通信不能なWebSocketを接続済みとして扱い続けると、利用者には「接続中」と表示されているのに最新データが届かない状態が発生します。リアルタイム機能では特に問題になりやすい状態です。

クライアント側では通知やチャットメッセージを受信できず、サーバー側でも切断済みのクライアントを長く管理してしまう可能性があります。また、再接続が開始されないため、ネットワークが利用可能になっても画面が古い状態のまま残ることがあります。

例えば監視ダッシュボードで接続ランプだけが緑色のまま、実際の数値は数分前から止まっているという状況です。heartbeatを利用すると、「一定期間応答がない」という基準から異常を検知し、切断や再接続へ移行できます。

2. heartbeatとは

2-1. 定期的に生存確認する仕組み

heartbeatは、一定間隔で相手へ確認用の信号を送り、応答が返ってくることによって通信経路が生きているか確認する仕組みです。「心拍」という名前のとおり、定期的な反応を確認します。

基本的な流れはシンプルです。クライアントが確認用メッセージを送り、サーバーが応答します。一定時間内に正常な応答を受信できれば接続は生きていると判断し、応答がない状態が続けば異常の可能性があると判断します。

例えばアプリケーションレベルでは、クライアントから{"type":"heartbeat_ping"}を送り、サーバーから{"type":"heartbeat_pong"}を返す方法があります。ここで使うpingpongという名前はアプリケーション独自のメッセージ名であり、WebSocketプロトコルのPing/Pong制御フレームとは別物です。

2-2. 応答がなければ異常と判断する

heartbeatでは、メッセージを送ること自体よりも「期待した時間内に応答が返ったか」を確認することが重要です。送信だけを続けても、通信相手に届いていることは確認できません。

そのため、heartbeatを送信した時点でタイムアウト判定を開始し、対応する応答を受信したら正常状態へ戻します。応答が得られない状態が一定の基準を超えた場合は、接続が正常ではない可能性が高いと判断します。

ただし、heartbeatの応答が1回遅れただけで必ず異常と断定すべきとは限りません。モバイル回線の一時的な遅延、ブラウザのバックグラウンド制御、サーバー負荷などでも応答が遅れる可能性があるため、許容時間や連続失敗回数はサービス要件に応じて設計します。

2-3. 再接続処理と組み合わせる

heartbeatは異常を検知する仕組みであり、再接続そのものではありません。通信不能だと判断した後にソケットを閉じ、別途用意した再接続処理へつなげます。

典型的な処理フローは次のようになります。

WebSocket接続
    ↓
heartbeat送信
    ↓
応答を受信
    ↓
正常 → 次のheartbeatを待つ

応答なし
    ↓
タイムアウト判定
    ↓
WebSocketをclose
    ↓
再接続処理へ
    ↓
接続成功
    ↓
heartbeatを再開

再接続では、指数バックオフやジッターなどを使って接続試行の集中を防ぐ設計が必要です。heartbeatで異常を早く検知できても、その直後に無制限な再接続を繰り返せば別の問題を生むため、「検知」と「復旧」は分けて考えます。

3. WebSocketのPing/Pongとは

3-1. PingとPongは制御フレーム

WebSocketプロトコルには、接続状態の確認などに利用できるPingフレームとPongフレームが定義されています。通常のテキストメッセージやバイナリメッセージとは異なり、WebSocketプロトコル自身が扱う制御フレームです。

RFC 6455では、Pingはオペコード0x9、Pongは0xAの制御フレームとして定義されています。制御フレームはペイロード長が125バイト以下で、分割して送ることはできません。

Ping/PongはWebSocket接続の生存確認や応答確認に利用できます。ただし、これらは"ping""pong"という文字列を通常メッセージとして送ることと同じではありません。プロトコル層の制御フレームとアプリケーションメッセージは明確に区別します。

3-2. Pingを受けた側はPongで応答する

WebSocketエンドポイントがPing制御フレームを受信した場合、接続がすでにClose状態へ進んでいる場合などを除き、Pongで応答します。RFC 6455では、この動作がプロトコルとして定められています。

Pingにアプリケーションデータが含まれている場合、応答するPongには原則として同じデータを含めます。これによって送信側は、どのPingに対応する応答なのか確認できます。

なお、PongはPingへの応答としてだけでなく、一方向のheartbeatとして送信することも仕様上認められています。この場合、そのPongに対してさらに別の応答を返す必要はありません。

3-3. ブラウザJavaScriptから直接操作できない点に注意する

ブラウザ標準のWebSocket APIでは、JavaScriptからPing/Pong制御フレームを直接送信するAPIは提供されていません。ここはサーバー向けWebSocketライブラリと混同しやすいポイントです。

ブラウザ側で利用できる主な操作は、new WebSocket()による接続、send()によるアプリケーションデータ送信、close()による切断、openmessageerrorcloseイベントの処理です。socket.ping()のような標準メソッドはありません。

Node.jsなどのサーバー環境で利用するWebSocketライブラリには、Ping/Pong制御フレームを明示的に扱える機能が用意されている場合があります。しかし、それは各ライブラリのAPIです。ブラウザ標準WebSocket APIの機能として説明しないよう注意してください。

4. アプリケーションレベルのheartbeat

4-1. 独自のpingメッセージを送信する

ブラウザからheartbeatを実装する場合は、通常のWebSocketメッセージとして独自の生存確認データを送る方法があります。これはWebSocketのPing制御フレームとは別の、アプリケーションレベルの仕組みです。

例えばJSONでメッセージ種別、識別子、送信時刻を含めます。

{
  "type": "heartbeat_ping",
  "id": "hb-123",
  "sentAt": 1720000000000
}

idを付けておくと、返ってきた応答がどのheartbeatに対応するものか確認できます。チャットや通知など通常の業務メッセージと区別できるよう、typeなどのフィールドを明確に分けることも重要です。

4-2. pong相当の応答を確認する

サーバーはアプリケーションheartbeatを受信したら、対応する応答を返します。例えばheartbeat_pingを受信したら、同じIDを持つheartbeat_pongを返します。

サーバー側の疑似コードは次のようになります。

onWebSocketMessage(connection, rawMessage):
    message = parseJson(rawMessage)

    if message.type == "heartbeat_ping":
        connection.sendJson({
            "type": "heartbeat_pong",
            "id": message.id
        })
        return

    handleApplicationMessage(message)

コードの役割: heartbeatメッセージを通常の業務メッセージから分離し、受信したIDをそのまま返しています。クライアントは同じIDのPong相当メッセージを受け取ることで応答を確認できます。

実行時の注意点: このheartbeat_pingheartbeat_pongは通常のWebSocketデータフレームで送るアプリケーションメッセージです。RFC 6455のPing/Pong制御フレームとは別物です。

4-3. タイムアウト時に接続を閉じる

heartbeatへの応答が一定時間得られない場合は、接続が正常ではない可能性があるため、そのWebSocketを閉じて復旧処理へ進めます。異常なソケットを使い続けないことが重要です。

ブラウザ側では、heartbeat送信時刻や最後に応答を受け取った時刻を保持します。許容時間を超えても応答がなければ、socket.close()を呼びます。closeイベントが発生した後は、アプリケーションの再接続処理へ引き渡します。

ただし、タイムアウト値は単純に短くすればよいわけではありません。ネットワーク遅延やバックグラウンドタブでのタイマー遅延を考慮し、多少の揺らぎを許容する必要があります。

5. heartbeatを実装する

5-1. 定期送信のタイマーを開始する

ブラウザでアプリケーションheartbeatを実装するときは、WebSocket接続が確立した後にheartbeat用タイマーを開始します。接続前から送信処理を開始すると、send()できない状態でエラーになります。

以下は、heartbeatの基本的な流れをまとめた例です。時間の値は説明用であり、すべてのサービスに適した推奨値ではありません。

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

// 説明用の値です。実際にはサービス要件に合わせて調整します。
const HEARTBEAT_INTERVAL_MS = 15000;
const HEARTBEAT_TIMEOUT_MS = 10000;

let socket = null;
let heartbeatIntervalId = null;
let heartbeatTimeoutId = null;
let pendingHeartbeatId = null;
let shouldReconnect = true;

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

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

    startHeartbeat();
  });

  socket.addEventListener("message", (event) => {
    const message = JSON.parse(event.data);

    if (message.type === "heartbeat_pong") {
      handleHeartbeatPong(message);
      return;
    }

    handleApplicationMessage(message);
  });

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

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

    stopHeartbeat();

    if (shouldReconnect) {
      scheduleReconnect();
    }
  });
}

function startHeartbeat() {
  stopHeartbeat();

  heartbeatIntervalId = setInterval(() => {
    sendHeartbeat();
  }, HEARTBEAT_INTERVAL_MS);

  // 接続直後にも必要なら1回実行できます
  sendHeartbeat();
}

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

  // 前回分の判定がまだ終わっているか確認します
  if (pendingHeartbeatId !== null) {
    return;
  }

  pendingHeartbeatId = crypto.randomUUID();

  socket.send(
    JSON.stringify({
      type: "heartbeat_ping",
      id: pendingHeartbeatId,
      sentAt: Date.now()
    })
  );

  heartbeatTimeoutId = setTimeout(() => {
    handleHeartbeatTimeout();
  }, HEARTBEAT_TIMEOUT_MS);
}

function handleHeartbeatPong(message) {
  if (message.id !== pendingHeartbeatId) {
    return;
  }

  console.log("heartbeat応答を確認しました");

  pendingHeartbeatId = null;

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

function handleHeartbeatTimeout() {
  console.warn(
    "heartbeat応答がないため接続異常と判断します"
  );

  pendingHeartbeatId = null;
  heartbeatTimeoutId = null;

  // close後はcloseイベント経由で再接続処理へ移ります
  if (socket !== null) {
    socket.close();
  }
}

function stopHeartbeat() {
  if (heartbeatIntervalId !== null) {
    clearInterval(heartbeatIntervalId);
    heartbeatIntervalId = null;
  }

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

  pendingHeartbeatId = null;
}

function handleApplicationMessage(message) {
  console.log("通常メッセージ:", message);
}

function scheduleReconnect() {
  console.log("再接続処理へ移行します");

  // 実際には指数バックオフやジッターを使った
  // 再接続処理を呼び出します
}

コードの役割: WebSocket接続成功後にheartbeatを開始し、一定間隔で確認メッセージを送信します。応答が届けばタイムアウトを解除し、届かなければ接続を閉じます。

正常時の流れ: open → heartbeat開始 → heartbeat_ping送信 → heartbeat_pong受信 → タイムアウト解除、という流れを繰り返します。

異常時の流れ: heartbeat送信 → 応答なし → タイムアウト → socket.close()closeイベント → heartbeat停止 → 再接続処理、という順番です。

停止方法: WebSocketのclose時には必ずstopHeartbeat()を実行します。また、利用者のログアウトや画面破棄によって意図的に切断する場合も、heartbeatのタイマーを停止してください。

5-2. 応答を受けたら生存状態を更新する

heartbeat応答を受信したら、その接続が少なくともその時点では往復通信できたと判断できます。タイムアウトタイマーを解除し、次のheartbeatを待ちます。

実装では、単にheartbeat_pongという種類だけを見るのではなく、送信時のIDと一致しているか確認すると安全です。古いheartbeatへの遅延応答を、新しいheartbeatの成功と誤認することを防げます。

また、必要であればDate.now()を保存して「最後に正常応答を受けた時刻」を画面表示や監視に利用できます。例えば管理画面に「最終通信確認: 10:32:15」と表示すると、接続状態を利用者や運用担当者が判断しやすくなります。

5-3. 応答がなければ再接続へ移行する

heartbeatのタイムアウトを検知したら、異常な接続を明示的に閉じ、通常の再接続処理へ移します。heartbeat自身が新しいWebSocketを作り続ける設計にするより、役割を分離したほうが管理しやすくなります。

例えばheartbeat側は「通信不能を検知してclose()するところまで」、再接続管理側は「closeを受けてバックオフ・ジッターを使いながら再接続するところまで」と分担できます。

再接続に成功したら、新しいWebSocketに対してheartbeatを再開します。また、必要に応じて認証状態、チャンネル購読、切断中に失ったイベントなども復元します。WebSocket接続が戻ったことと、アプリケーション状態が完全に戻ったことは別問題です。

6. heartbeat設計の注意点

6-1. 間隔を短くしすぎると通信量が増える

heartbeat間隔を短くすると異常を早く検知できますが、その分だけ通信回数が増えます。利用者数が多いサービスでは、heartbeat自体が無視できない通信量になる可能性があります。

例えば1クライアントだけなら短い間隔でも影響が小さく見えますが、同時接続クライアントが増えると、すべての接続でPing相当メッセージとPong相当メッセージが発生します。サーバーではメッセージ解析や応答処理も必要です。

heartbeat間隔は、「どのくらい早く通信不能を検知する必要があるか」から決めます。チャットのオンライン表示と、金融取引や遠隔操作の接続監視では要求が異なるため、特定の秒数を唯一の正解として採用すべきではありません。

6-2. タイムアウトを厳しくしすぎると誤検知が増える

heartbeatのタイムアウトを短くしすぎると、実際には接続可能なのに異常と判断してしまう可能性があります。ネットワークには一時的な遅延があるためです。

例えば通常は数百ミリ秒で応答する通信でも、モバイル回線の切り替えや端末負荷によって一時的に応答が遅れることがあります。ブラウザがバックグラウンドにある場合はJavaScriptタイマーそのものが正確な時刻どおりに実行されない場合もあります。

そのため、「heartbeat間隔10秒ならタイムアウトも10秒」と機械的に決めるのではなく、実測した通信遅延、許容できる検知時間、バックグラウンド利用の有無などを考慮します。場合によっては1回の失敗で閉じず、連続失敗回数を判断材料にする方法もあります。

6-3. タイマーの停止漏れや二重起動を防ぐ

heartbeat実装では、タイマーを複数起動してしまう問題にも注意が必要です。再接続のたびに新しいsetInterval()を作り、古いタイマーを解除しなければ、heartbeatが重複送信されます。

例えば「接続→heartbeat開始→切断→再接続→heartbeat開始」と進んだとき、最初のタイマーが残っていると2本のheartbeatが動きます。再接続を繰り返すたびにタイマーが増え、送信量やタイムアウト判定がおかしくなります。

対策として、heartbeat開始前に既存タイマーを停止し、close時にも必ず解除します。タイマーIDを変数として管理し、nullかどうかで起動状態を確認すると実装しやすくなります。

画面離脱やログアウト時にも同様です。WebSocketだけを閉じるのではなく、setInterval()setTimeout()、再接続タイマーなど、その接続に関連するすべての処理を停止します。

7. まとめ

7-1. heartbeat・Ping/Pong・再接続の役割を整理する

heartbeat、WebSocketのPing/Pong、再接続は関連していますが、それぞれ役割が異なります。混同せずに分けて理解することが重要です。

WebSocketのPing/PongはRFC 6455で定義されたプロトコルレベルの制御フレームです。一方、ブラウザJavaScriptからはPing/Pong制御フレームを直接操作できないため、必要に応じて通常のWebSocketメッセージとしてアプリケーションレベルのheartbeatを実装します。

heartbeatの目的は、一定時間応答がない接続を検知することです。異常を検知した後は接続を閉じ、再接続処理へ引き渡します。再接続成功後にはheartbeatを再開し、必要に応じて認証・購読・データ状態も復元します。

整理すると、次のようになります。

仕組み主な役割ブラウザJavaScriptからの操作
WebSocket Ping/Pongプロトコルレベルの生存確認・制御制御フレームを直接送信できない
アプリケーションheartbeatアプリ独自の生存確認send()で実装できる
timeout判定応答がない接続を異常と判断するJavaScriptで実装する
close()異常な接続を終了するJavaScriptから実行できる
再接続新しいWebSocket接続を作るアプリケーション側で実装する

7-2. 生存確認を実装するときのチェックリスト

heartbeatを導入するときは、「何秒ごとに送るか」だけでなく、何をもって正常と判断し、どの時点で異常と判断するのかを設計します。利用するネットワークやブラウザの動作も考慮する必要があります。

また、サーバー側にもheartbeatへの応答処理が必要です。クライアントだけで完結する仕組みではなく、プロトコルレベルまたはアプリケーションレベルで双方が対応して初めて生存確認として機能します。

実装時には、次の項目を確認してください。

  • readyState === OPENだけで正常と判断していないか
  • heartbeatの目的を生存確認・異常検知として整理しているか
  • WebSocket制御フレームのPing/Pongと独自メッセージを区別しているか
  • ブラウザJavaScriptからPing制御フレームを直接操作できないことを理解しているか
  • heartbeatメッセージを通常の業務メッセージと識別できるか
  • heartbeatごとにIDを付ける必要があるか検討したか
  • 応答タイムアウトを設定しているか
  • タイムアウトを必要以上に短くしていないか
  • 一時的なネットワーク遅延を考慮しているか
  • heartbeat間隔による通信量を確認しているか
  • バックグラウンドタブでのタイマー挙動を考慮しているか
  • タイムアウト時に異常なWebSocketを閉じているか
  • close後の再接続処理を別途用意しているか
  • 再接続ではバックオフやジッターを検討しているか
  • 再接続成功後にheartbeatを再開しているか
  • heartbeatタイマーが二重起動しないか
  • 切断・ログアウト・画面破棄時にタイマーを停止しているか
  • サーバー側でも不要な接続を解放できるか
  • 実際のネットワーク切断を再現してテストしているか

heartbeatはWebSocket接続を切れなくする魔法の仕組みではありません。接続が通信可能かを継続的に観測し、異常を適切なタイミングで発見して、安全な切断と再接続につなげるための仕組みです。

8. 参考リンク