- リアルタイムWebアプリでは、サーバー-クライアント間のイベント配信に Long Polling、WebSockets、SSE、WebRTC、WebTransport のどれを選ぶかによって、レイテンシ、双方向性、実装難易度、運用上の制約が大きく変わる
- WebSockets は単一の長期接続で双方向通信を提供するが、実運用では接続断の検知、再接続、ping-pong ハートビートのために Socket.IO のようなライブラリを併用することが多い
- Server-Sent Events は HTTP ベースのサーバー→クライアント単方向ストリームなので、実装や再接続処理はシンプルだが、標準の EventSource API には POST ボディやカスタムヘッダー送信の制約がある
- WebTransport は HTTP/3 QUIC ベースのマルチストリームと信頼・非信頼転送をサポートするが、2024年3月時点では Working Draft であり、Safari や Node.js のネイティブ対応がなく、まだ汎用的な選択肢とは言いにくい
- モバイルのバックグラウンド終了、ドメインごとの接続数制限、企業プロキシ・ファイアウォール、再接続中のイベント欠落のため、実際のアプリでは 同期回復ロジックとインフラテスト もあわせて必要になる
リアルタイムなサーバー-クライアント通信技術の流れ
- リアルタイムWebアプリケーションでは、サーバーがクライアントへイベントを送る機能が中核的な要件になっている
- 当初は HTTP 上で動作する Long Polling が、ブラウザで利用できるサーバー-クライアントメッセージング方式として使われていた
- その後、双方向通信のためのより堅牢な方式として WebSockets が登場した
- Server-Sent Events(SSE) はサーバーからクライアントへのみ送る単方向通信を、よりシンプルに提供する
- WebTransport は、より効率的で柔軟かつスケーラブルな方式になる可能性があるが、現時点ではサポート範囲が限られている
- WebRTC は一部のニッチなサーバー-クライアントイベント用途で検討できるが、主要な選択肢として扱うには目的が異なる
Long Polling
- Long Polling は通常の XHR リクエストでサーバープッシュ通信を擬似的に実現する方式
- クライアントがサーバーにリクエストを開いたままにしておくと、サーバーは新しいデータが生じるまでレスポンスを保留する
- 新しい情報を送った後に接続は閉じられ、クライアントはただちに次のリクエストを再開する
- 従来の定期ポーリングより更新は速く、不要なネットワークトラフィックやサーバー負荷を減らせる
- ただし WebSockets のようなリアルタイム技術より効率は低く、データ送信のタイミングによっては遅延が生じうる
- クライアント実装は簡単だが、バックエンドでは再接続中のクライアントがイベントを取りこぼさないよう保証するのが難しい
WebSockets
- WebSockets はクライアントとサーバーの間に単一の長期接続を作り、全二重(full-duplex)通信 を提供する
- 接続が確立すると、HTTP のリクエスト-レスポンスサイクルのオーバーヘッドなしに、双方が独立してデータを送れる
- リアルタイムチャット、ゲーム、金融取引プラットフォームのように、低レイテンシかつ頻繁な更新が必要なアプリに向いている
- 基本の WebSocket API は使いやすいが、本番運用では接続断や再生成の処理が複雑になる
- 接続がまだ利用可能かを検知しづらいため、通常は ping-and-pong ハートビートを追加する
- こうした複雑さから、多くの場合は Socket.IO のようなライブラリを使い、Socket.IO は必要に応じて Long Polling フォールバックも提供する
Server-Sent Events
- Server-Sent Events(SSE) は HTTP 上でサーバー更新をクライアントへプッシュする標準的な方式
- WebSockets と異なり、サーバー→クライアントの単方向通信専用に設計されている
- ライブニュースフィード、スポーツのスコア、リアルタイム更新のように、クライアントがサーバーへメッセージを送る必要がない場面に適している
- SSE は、1つの HTTP リクエストで接続を維持しながら、イベントが発生するたびにバックエンドがレスポンスを1行ずつ流す方式と見なせる
- ブラウザクライアントでは EventSource インスタンスを初期化してイベントストリームを受け取る
- EventSource は WebSockets と違って、接続が切れると自動で再接続する
- サーバーは
Content-Typeヘッダーをtext/event-streamに設定し、SSE specification に従ってイベントタイプ、データペイロード、イベント ID、retry timing などのフィールドを整形する必要がある
WebTransport
- WebTransport は Web クライアントとサーバー間の効率的で低レイテンシな通信のための API
- HTTP/3 QUIC protocol を活用し、複数のストリームでデータを送れる
- 信頼性のある転送と信頼性のない転送、順不同のデータ転送をあわせてサポートする
- リアルタイムゲーム、ライブストリーミング、共同作業プラットフォームのように高性能なネットワーキングが必要なアプリにとって強力なツールになりうる
- 2024年3月時点で WebTransport は Working Draft の状態であり、広くサポートされていない
- Safari browser ではまだ利用できず、Node.js にもネイティブサポートがない
- サポートが広がったとしても API は非常に複雑で、アプリケーションコードから直接使うより、WebTransport 上にライブラリを作る形になる可能性が高い
WebRTC
- WebRTC は、ブラウザやモバイルアプリ内でプラグインなしにリアルタイム通信機能を提供するオープンソースプロジェクトであり API 標準でもある
- ブラウザ間の音声、映像、データ交換のための peer-to-peer 接続をサポートする
- NAT やファイアウォールを越えるために ICE、STUN、TURN などのプロトコルを使う
- WebRTC はクライアント-クライアント相互作用のために作られたが、サーバーをクライアントのように動作させてサーバー-クライアント通信に活用することもできる
- この方式はニッチなユースケースにしか合わないため、主要な選択肢の比較からは除外される
- WebRTC を動かすにはどうせシグナリングサーバーが必要で、このサーバーは WebSockets、SSE、WebTransport のいずれかの上で動くことになる
- そのため WebRTC をこれら技術の直接の代替として使う意義は薄い
技術ごとの主要な制約
-
双方向データ転送
- 同じ接続でサーバーデータを受け取り、クライアントデータを送れるのは WebSockets と WebTransport だけ
- Long Polling も理論上は可能だが、既存の long-polling 接続に新しいデータを送るには追加の HTTP リクエストが必要になるため推奨されない
- Long Polling では既存接続を妨げずに、別の HTTP リクエストでクライアント→サーバーデータを送るほうがよい
- SSE はサーバーへ追加データを送る機能をサポートしない
- 標準のネイティブ EventSource API は、初期リクエストでも HTTP body に POST のようなデータを送れない
- データを URL パラメータに入れる必要があるが、credentials がサーバーログ、プロキシ、キャッシュに漏れる可能性があり、セキュリティ上望ましくない
- RxDB はこの問題を避けるため、ネイティブの
EventSource APIの代わりに eventsource polyfill を使っており、このライブラリは カスタム HTTP ヘッダー などの機能を追加する - Microsoft の fetch-event-source は body データ送信と、
GETではなくPOSTリクエストの使用を許可する
-
ドメインごとの接続数制限
- ほとんどの最新ブラウザはドメインごとに 6 接続 を許可しており、この制限は安定したサーバー→クライアントメッセージング方式全般の使い勝手を制約する
- 6 接続制限はブラウザタブ間でも共有されるため、同じページを複数タブで開くと、各タブは同じ接続プールを分け合う必要がある
- HTTP/1.1 RFC はさらに低い値として、サーバーまたはプロキシごとに 2 接続 を推奨している
- この方針は訪問者を利用した DDoS を防ぐうえでは妥当だが、正当なサーバー-クライアント通信で複数接続が必要な場合には問題になりうる
- 回避するには HTTP/2 または HTTP/3 を使い、ブラウザがドメインごとに単一接続だけを開き、多重化(multiplexing) でデータを処理するようにする必要がある
- HTTP/2・HTTP/3 でも SETTINGS_MAX_CONCURRENT_STREAMS 設定が実際の同時ストリーム数を制限し、多くの構成でのデフォルト値は 100 concurrent streams である
- EventSource のような特定 API に対してブラウザ側が接続制限を増やす可能性はあるが、Chromium と Firefox の関連 issue は “won’t fix” とされている
-
ブラウザアプリでの接続数削減
- ブラウザアプリでは、ユーザーが同じアプリを複数タブで同時に開く前提で考える必要がある
- デフォルトではタブごとに1本のサーバーストリーム接続を開けるが、多くの場合それは不要である
- 複数タブが開いていても、単一接続だけを開いてタブ間で共有する方式が可能である
- RxDB は broadcast-channel npm package の LeaderElection を使い、サーバーとクライアント間の replication stream を1本だけ維持する
- このパッケージは RxDB なしでも、他のアプリケーションで単独利用できる
モバイル、プロキシ、ファイアウォールの運用上の制約
- Android や iOS のようなモバイル OS では、WebSockets を含むオープン接続を維持し続けるのが難しい
- モバイル OS は一定時間非アクティブな状態が続くと、アプリをバックグラウンドに送り、開いている接続を閉じることがある
- これはバッテリー節約と性能最適化のためのリソース管理戦略の一部である
- 開発者は、サーバーがクライアントへデータを送る際に、持続接続の代わりに モバイルプッシュ通知 を使うことが多い
- プッシュ通知は、持続的なオープン接続なしに、サーバーが新しいデータをアプリへ知らせ、アプリの動作や更新を促せる
- 企業環境では、プロキシやファイアウォールが非 HTTP 接続を遮断し、WebSocket サーバーをインフラに組み込みにくいことがある
- そのような環境では、HTTP ベースの SSE のほうが企業統合しやすい方式になりうる
- Long Polling も通常の HTTP リクエストしか使わないため、選択肢になりうる
性能比較
- WebSockets、SSE、Long Polling、WebTransport を比較する際には、レイテンシ、スループット、サーバー負荷、スケーラビリティをあわせて見る必要がある
- Go サーバー実装でメッセージ時間をテストした realtime-web repo は、WebSockets、WebRTC、WebTransport の性能が似ているという結果を示している
- WebTransport は HTTP/3 ベースの新技術なので、2024年3月以降さらに多くの性能最適化が出てくる可能性がある
- WebTransport は電力使用を減らすよう最適化されているが、その指標はテストされていない
-
レイテンシ
- WebSockets は単一の持続接続上の全二重通信のため、最も低いレイテンシを提供する
- SSE もサーバー→クライアント通信では低レイテンシを提供するが、クライアントがサーバーへメッセージを送るには追加の HTTP リクエストが必要になる
- Long Polling はデータ転送のたびに新しい HTTP 接続を作るため、レイテンシがより高い
- Long Polling では、サーバーがイベントを送ろうとした瞬間にクライアントが新しい接続を開いている途中だと、レイテンシが大きく増えることがある
- WebTransport は WebSockets に近い低レイテンシを提供すると期待されており、HTTP/3 のより効率的な多重化と congestion control を活用する
-
スループット
- WebSockets は持続接続のおかげで高いスループットを出せるが、クライアントがサーバー送信速度に追いつけない backpressure の問題がスループットに影響する可能性がある
- SSE は WebSockets よりオーバーヘッドが少ないため、単方向のサーバー→クライアントブロードキャストでは潜在的により高いスループットを出せる
- Long Polling は接続の頻繁なオープンとクローズのオーバーヘッドのため、一般にスループットが低く、サーバーリソースもより多く消費する
- WebTransport は単一接続内で単方向・双方向ストリームの両方に高いスループットを提供すると見込まれ、複数ストリームが必要なシナリオでは WebSockets を上回る可能性がある
-
スケーラビリティとサーバー負荷
- WebSockets は多数の接続を維持するほどサーバー負荷が大きく増える可能性があり、ユーザー数の多いアプリのスケーラビリティに影響しうる
- SSE は主にサーバー→クライアント更新が必要なシナリオで、よりスケーラブルである
- SSE は WebSocket のような protocol upgrade 手順なしに通常の HTTP リクエストを使うため、接続オーバーヘッドがより低い
- Long Polling は頻繁な接続確立によってサーバー負荷が大きく、最もスケーラビリティが低く、フォールバックメカニズムとしてのみ適している
- WebTransport は HTTP/3 の接続・ストリーム処理効率を土台に高いスケーラビリティを目指して設計されており、WebSockets や SSE よりサーバー負荷を減らせる可能性がある
ユースケース別の推奨
- SSE は実装が最も直感的な選択肢であり、既存の HTTP/S プロトコルを使うため、企業ファイアウォールの制限や他プロトコルで起こりうる技術的問題を回避しやすい
- Node.js や他のサーバーフレームワークに容易に統合できる
- ニュースフィード、株価、ライブイベントストリーミングのように、サーバー→クライアント更新が頻繁に必要なアプリに向いている
- WebSockets は継続的な双方向通信が必要なシナリオに強い
- ブラウザゲーム、チャットアプリケーション、ライブスポーツ更新のように、継続的な相互作用が必要な場合の主な選択肢になる
- WebTransport には将来性があるが、サーバーフレームワークの対応が広くなく、Node.js と Safari の互換性も不足している
- WebTransport は HTTP/3 に依存しているが、nginx のような多くの Web サーバーにおける HTTP/3 サポートはまだ experimental 状態である
- 信頼・非信頼データ転送の両方をサポートする将来志向の技術だが、現在の大半のユースケースではまだ実用的な選択肢ではない
- Long Polling は、繰り返し新しい HTTP 接続を確立する非効率さと高いオーバーヘッドのため、総じて旧式の方式である
- WebSockets や SSE をサポートしない環境ではフォールバックとして使えるが、性能上の制約から一般的な利用は推奨されない
再接続中のイベント欠落問題
- どのリアルタイムストリーミング技術の上に機能を作る場合でも、接続中断と再接続を考慮する必要がある
- クライアントが接続中、再接続中、あるいはオフラインの場合、サーバー側で発生したイベントをストリームで受け取れないことがある
- サーバーが株価のように毎回全内容をストリーミングするなら、欠落イベントは重要でないかもしれない
- バックエンドが部分結果だけをストリーミングするなら、欠落イベントを必ず処理しなければならない
- バックエンドがクライアントごとに、どのイベントが正常送信されたかを記憶する方式はスケーラブルではない
- この問題はクライアント側ロジックで処理するほうがよい
- RxDB Sync Engine は2つの動作モードを使う
- checkpoint iteration mode: 通常の HTTP リクエストでバックエンドデータを繰り返し取得し、クライアントが再同期されるまで追いつく
- event observation mode: リアルタイムストリームの更新でクライアントを同期状態に保つ
- クライアント接続が切れたりエラーが発生したりすると、replication は一時的に checkpoint iteration mode に切り替わり、再びサーバーと同じ状態になるまで同期する
- この方式により、欠落イベントを補正し、クライアントが常にサーバーと正確に同じ状態に同期できる
企業インフラでの確認事項
- 企業インフラでは、ストリーミング技術全般で問題が発生する可能性がある
- プロキシやファイアウォールはトラフィックを遮断したり、リクエスト・レスポンスを意図せず壊したりすることがある
- そのような環境でリアルタイムアプリを実装する際は、選んだ技術そのものがそのインフラ上で動作するかをまずテストする必要がある
1件のコメント
Hacker Newsの意見
Server-Sent Events は前から気に入っている。シンプルで、書くのも実装するのも簡単
WebSocket は利用量がある水準を超えると、スケーリングがかなり複雑になる
https://crbug.com/275955
なぜ単なる multipart ストリーミングレスポンスにしなかったのか気になる。メタデータもサポートできるし、とても一般的に実装されている形式なのに
さらに知っておくべき欠点がある。
WebSocket にはフロー制御(バックプレッシャー)と多重化がないため、必要なら自前で作るか RSocket のようなものを使う必要がある。SSE もバイナリデータを直接送れないので、base64 のようなエンコーディングが必要になる。
WebTransport はこれらの問題を扱い、HOL ブロッキングも解消するが、Python 2→3 や IPv6 移行のように、人々が既存バージョンを使い続けやすく、アップグレードの利点が小さく感じられる問題が起きるのではないかと心配している。
ブラウザが TCP 上で動き続ける限り、一部のネットワークは UDP、したがって HTTP/3/WebTransport を丸ごと遮断できる
WebTransport への移行が遅れるかもしれないという懸念は、以前なら TLS トランスポート、HTTP/3、XHR に対しても同じように言えたことだ。主要なブラウザエンジンがいくつか市場を支配している構造なので、新しいブラウザ機能やプロトコルの展開は比較的容易だ。
TCP は許可されているのだから一部ネットワークが UDP を止められる、という理屈なら、HTTP 1.1 の非 TLS が使えるのだから HTTP/2 や TLS もずっと止められる、という話に近い。完全に間違いではないが、HTTP/2 と特に TLS の広範な採用を見ると、思ったほど大きな問題ではなさそうだ
小さなオフィスや映画に出てきそうなディストピア的企業環境では閉じられるかもしれないが、一部ネットワークが UDP を禁止できるという事実が、なぜそんなに重要なのか分からない。google.com や wikipedia.com を遮断するネットワークもあるが、だからといってそれらのサービスが失敗しているわけではない
記事の WebRTC の説明は正確ではない。クライアント/サーバー WebRTC は別個の「シグナリングサーバー」なしでも可能で、サーバーがシグナリングを担えばよい。
往復が数回増えるだけで、別のサーバーが必要になるわけではない。WebRTC データチャネルは WebSocket や SSE の代替としてかなりうまく機能し、とくに HOL ブロッキングを避けたいときに向いている。Pion や str0m のように、ほぼすべての作業をしてくれるライブラリも多い。
WebTransport API が複雑だという話も誇張に見える。高度な機能が必要なければ無視すればよく、WebSocket のように使いたいなら双方向ストリームを 1 本開けばほぼ終わりだ。HOL ブロッキングを避けたいなら、メッセージごとにストリームを開けばよい。少し複雑にはなるが、ライブラリが必須というほどではなく、GitHub Copilot がコードを書いてくれる可能性も高い。ただし WebTransport はまだ成熟途上で、サーバーライブラリは多くなく、Safari のサポートも待っているところだ
普通、シグナリングサーバーは WebSocket で実装される。既存クライアントによる分散型ブートストラップを提案しているのでなければ、WebRTC 自体で実装することはできない
伝統的な「エンタープライズ」と「セキュリティ」IT インフラを持つ顧客向けに作るなら、更新ボタンを追加して終わりにした方がよい。
こうした環境では、私の経験上、絶えず失敗し、果てしない手続きのせいで修正もできないもの、それがまさにこうした顧客向けにリアルタイム機能を作ろうとする試みだった
WebSocket と SSE は、規模が大きくなると運用が大きな悩みの種になる。特にバックエンドでは別途オブザーバビリティが必要で、モバイル端末ではかなり慎重に実装しないとフロントエンドのデバッグが悪夢になる
端末はバッテリー節約のためにネットワークを切ったり遅くしたりするし、専用 API で明示的に I/O を行わなければなおさらそうなる
新しい接続の作成はコストの高い処理であり、サーバーは状態をどこかに保存しなければならない。この状態保存レイヤーに問題が起きると、クライアントは再試行とタイムアウトを繰り返し、高コストな処理にいつまでも捕まり続ける。スループットを簡単に制御しつつ、データベースにゆっくり負荷をかけるようなやり方でもない
信頼性の面では、経験上 ロングポーリング が最も良かった。イベント駆動のフローが本当に重要でも、フロントエンドは第1層バックエンドにロングポーリングし、その第1層が第2層バックエンドを WebSocket で購読する 2 層構成のほうがよく、信頼性の制御もしやすくなる
SSE は自動再接続をサポートし、最後に見た ID も含められるので、サーバーは途切れずに再開できる
記事にはないが、ショートポーリング も関係がある。これはサーバーからクライアントへメッセージを送る方式ではないが、共有ホスティングのように他の選択肢がないときには依然として有用だ
私の経験では、ポーリング間隔が長くても、たとえば 20 秒でも、各レスポンスにメッセージ一覧を含めればかなりうまく動く。ユーザーがボタンを押すとクライアントがサーバーにリクエストを送り、サーバーはデータと最新のメッセージ一覧をまとめて返すので、クライアントは最新状態になる
WebSocket と SSE が、初回リクエストで Authorization のようなヘッダー の送信をいまだにサポートしていない理由がわからない。リアルタイムサービスの認証をすべて実装者任せにしている
仕様上よい方法があるのかもしれないが、あまりに多様なやり方を見てきたので、今では事実上ないと言っていいくらいだ
カスタムヘッダーの処理だけでなく、すべてのリクエストメソッド(POST、PATCH など)、リクエストボディの送信、名前付きイベントの購読、初期 last event ID の設定もサポートしている。非同期イテレータとして使うこともできる
Server-Sent Events のシンプルさは気に入っているが、
EventSourceAPI は急いで実装されたまま放置されたように見える[1]: https://github.com/eventsource/eventsource
素朴な考えかもしれないが、HTTP/2 以上 を前提にするなら、EventSource とメッセージ送信用
fetch()の組み合わせは、単一の TCP 接続を使う他のプロトコルと同じくらい十分に見える。HTTP/3 は UDP を使うので、なおさらよいタブがフォアグラウンドにあるときだけ接続維持が必要、という前提だ。実際にこの方法を試したときにどんな問題があったのか気になる
https://www.npmjs.com/package/@microsoft/fetch-event-source
まったく別のものを使うより、レイテンシ、メモリ使用量、CPU リソースをさらに下げつつ SSE をもっと突き詰められないか気になっていた
こういう記事を見ると少し面白い。90 年代後半にオンラインオークションシステムを設計したが、XHR リクエスト はまったくなかった
リアルタイム更新はすべて server-push / HTTP ストリーミングで処理していた。当時はオープンな接続をすべて扱うのは簡単ではなかったが、適切なアーキテクチャがあれば受け入れ可能な規模までは可能だった
HTTP/2 や HTTP/3 の利点はすばらしいが、事実上どこでもサポートされている HTTP 1.1 で活用できることも知っておく必要がある
ロングポーリングが少し懐かしい。最新技術と比べると本当に単純だった。WebRTCが最高だと考える立場でもそう感じる
その代わり、再びデータを待って同じストリームで追加のレスポンスを送る
Second Life のネットワーキングでは「イベントチャネル」にロングポーリング HTTPS を使い、サーバーがそのチャネルでクライアントにイベントメッセージを送る。ほとんどのメッセージは UDP で送られるが、暗号化が必要だったり大きなメッセージだったりする場合は HTTPS/TCP のイベントチャネルに送られる
クライアント側の C++ クライアントは
libcurlを使っているが、デフォルトのタイムアウト設定がロングポーリングに合っていない。libcurlが接続を切って新しいリクエストを作り、その結果メッセージの欠落や重複が起こりうるサーバー側では Apache が実際のシミュレーションサーバーの前段に立って無関係な接続試行をふるい落としているが、Apache にも独自のタイムアウトがあるため、接続を中断してクライアントの再試行を引き起こす
メッセージのシーケンス番号で欠落を防ごうとしているが、Second Life サーバーはクライアントが確認用に返すシーケンス番号を無視する。Open Simulator の一部の互換サーバーは連番を飛ばすことさえある
その結果、本来は信頼できるはずのメッセージを失ったり重複させたりする HTTPS ベースのシステムになっている。いくつかのメッセージは失われるとゲーム内のユーザー活動が止まってしまう
これを設計した人たちはずっと前に去っており、現在の社員たちはこの混乱がどれほど深刻かを知らなかった。外部ユーザーが問題を見つけて文書化する必要があり、会社の社員たちは何か月も修正しようとしている。十分に難しいため、現時点では作業を先送りしているように見える
だからロングポーリングは「ばかみたいに単純」ではない。正しいやり方は、TCP と HTTPS の層がタイムアウトしない程度に十分な頻度で keep-alive メッセージを送ることなのだろう。そうすれば Apache と
libcurlが正常に動作する経路に留まれる