SSEのハートビートをJS側で検知したいなら、ネイティブEventSourceでは不可能—fetch+ReadableStreamの手動パースが必要
Server-Sent Eventsで「接続が生きていること」をクライアントJS側のタイマーで判定したい場合(例: 一定時間メッセージが無ければポーリングに降格するといったフォールバック制御)、サーバーが送るSSEコメント行(: で始まる行、例えば: ping)をハートビートに使う設計は成立しない。
原因: ブラウザのネイティブEventSourceはSSE仕様上のコメント行をパース段階で完全に破棄し、onmessageもaddEventListenerも一切発火しない。コメント行は仕様上「TCP/HTTP接続をインフラ層(リバースプロキ・ロードバランサのアイドルタイムアウト)から守る」目的でしか使えず、アプリケーション層の「最後に何か受信したか」タイマーをリセットする用途には使えない。
適用条件: 「ハートビート受信を契機にクライアント側の状態(例: 無音判定タイマー)を変えたい」ケースに限って問題になる。単に「中間プロキシーのアイドル切断を防ぐ」だけならコメント行で十分(ブラウザJSは関与しなくてよい)。
対処方法(クライアント側でハートビートを検知したい場合の選択肢): (1) サーバー側でハートビートをコメント行ではなく名前付きイベント(event: heartbeat\ndata: {}\n\n)や通常のdata:フレームとして送り、クライアントはaddEventListener/onmessageで受け取ってタイマーをリセットする。(2) EventSourceを使わずfetch+ReadableStreamでSSEを自前パースし、受信バイト列(コメント行も含む)を検出した時点でタイマーをリセットする。後者はSSE仕様を完全に守りつつ、コメント行も含めて「何か受信した事実」を検知できるが、自前パースの実装コストと引き換えになる。
確認方法: ブラウザの開発者ツールではなく、curl等の低レイヤツールでSSE応答を直接観察してコメント行が本当に届いているかを確認した上で、ブラウザ側の実装をEventSourceの挙動で検証する(コメント行受信時にJS側のタイマーがリセットされないことを実際に確認すれば、この制約に気付ける)。
教訓: 「SSEにハートビートを追加すればクライアントの無音判定が改善するはず」という直感は、ハートビートの送信方式(コメント行かイベントか)と受信側のパーサ(完全なEventSourceか手動パースか)の組み合わせ次第で成り立たない。仕様通りのコメント行を選ぶなら受信側も手動パースにする必要があり、実装コストを受け入れてもJS側の可視性が欲しければ名前付きイベントの方が安価。