Server-Sent Events
Server-Sent Events ・ さーばーせんといべんつ
HTTP の応答を閉じずに開いたまま、サーバからブラウザへ一方向にイベントを流し続ける仕組み。
✦ 30秒で試す — 航海HTTPの返事を、わざと終わらせずに開けっぱなしにする再接続の処理を書いていないブラウザは、どうなる?予想してみる概要
Server-Sent Events(SSE)は、サーバからブラウザへ一方向にデータを流し続けるための標準の仕組みです。ふつうの HTTP は「ブラウザが尋ね、サーバが1回答えたら終わり」ですが、SSE ではレスポンスを終わらせずに接続を開いたままにし、サーバ側で何かが起きるたびにその接続へイベントを書き足していきます。通知、進捗の表示、ログの流し見、株価やスポーツの速報のように、「サーバ側の変化をすぐ画面へ反映したい」場面で使われます。
特徴はその素朴さです。別のプロトコルへの切り替えは行わず、あくまで「終わらない HTTP レスポンス」として動くので、既存のサーバやリバースプロキシの多くがそのまま通します。ブラウザ側も EventSource という組み込みの API を数行書くだけで受け取れ、切れたときの自動再接続まで標準で備えています。近年は LLM のチャットの応答を生成したそばから流す、ストリーミング API の定番の形式として、改めて広く使われています。
ただし「HTTP のままだから、いつもの認証がそのまま使える」とは限りません。ブラウザの EventSource は任意のヘッダを付けられないので、Authorization ヘッダでトークンを送ることができないのです。この落とし穴は詳細で扱います。
なぜ生まれたか
HTTP は、ブラウザが尋ねてサーバが答える形しか持っていません。サーバ側の更新をブラウザに知らせたいとき、最初に使われたのは、ブラウザが定期的に「何か変わった?」と問い合わせるポーリングでした。間隔を短くするとサーバの負荷と通信量が跳ね上がり、そのほとんどは「変化なし」という空振りです。間隔を長くすると、今度は反映が遅れます。
2000年代半ばには、この板挟みを HTTP の変則的な使い方で抜ける工夫が広まりました。サーバが応答をわざと保留して、イベントが起きた瞬間に返すロングポーリングや、隠した iframe に終わらないページを流し続ける手法です。まとめて「Comet」と呼ばれたこれらは遅れを縮めましたが、ブラウザごとの癖への対処や、応答のたびに接続を張り直す手間が付きまとい、実装は職人芸になりがちでした。
そこで HTML の標準化の中で、「多くの用途は双方向ではなく、サーバからの一方向の通知で足りる」という観察に基づき、HTTP の枠内で最小限のプッシュを正式な形にしたのが SSE(EventSource API と text/event-stream 形式)です。全二重の WebSocket が「新しい通信路を開く」やり方だとすれば、SSE は「今ある HTTP の応答を終わらせない」やり方です。
詳細
仕組み — 終わらないレスポンスと text/event-stream
ブラウザで new EventSource('/events') と書くと、ふつうの GET リクエストが飛び、サーバは Content-Type: text/event-stream でレスポンスを返し始めます。ただし本文を完結させず、イベントが起きるたびに data: 本文 という行を書き、空行で区切って送り出します。形式はただのテキストで、event: でイベントの種類、id: で通し番号、retry: で再接続までの待ち時間(ミリ秒)も指定できます。
自動再接続は SSE の隠れた主役です。接続が切れると、ブラウザは retry: で指定された時間(既定は数秒)待ってから自動的につなぎ直し、そのとき最後に受け取ったイベントの id を Last-Event-ID ヘッダで送ります。サーバがこれを見て「それより後のイベント」から送り直せば、切れていた間の取りこぼしを防げます。ただし、これが働くのはサーバがイベントに id: を付け、送り直せるだけの履歴を持っている場合だけです。WebSocket では再接続も欠落の補償もすべて自前で実装するので、この差は小さくありません。
下の部品で、ポーリングの間隔を動かしてから SSE に切り替え、回線を切ってみてください。空振りの往復、届くまでの遅れ、再接続で続きから届く様子が見えます。
サーバでは不定期にイベント(新着通知)が起きています。いまは3秒ごとのポーリングです。間隔を動かして、空振りの往復(破線の封筒)と、下の帯の斜めの線(起きてから届くまでの遅れ)を比べてください。
WebSocket との使い分け
選ぶ軸は「双方向が本当に必要か」です。チャットの送信、共同編集、オンラインゲームのように、ブラウザからも頻繁にデータを送るなら WebSocket が向きます。通知・フィードの更新・進捗・ログの表示のように、流れが「サーバからブラウザへ」の一方向なら SSE で足ります。HTTP のままなので、圧縮や、リバースプロキシ・CDN を通す運用で楽ができます。なお SSE はテキスト専用で、バイナリを流したいなら WebSocket(あるいはより新しい WebTransport)の出番です。ブラウザから送りたいことがたまにある程度なら、「受信は SSE、送信はふつうの POST」という組み合わせも定番です。
認証の落とし穴 — EventSource はヘッダを付けられない
ブラウザの EventSource は、URL と「Cookie を送るか」(withCredentials)しか指定できません。Authorization: Bearer … のようなヘッダは付けられないので、API をトークンで守っている場合、そのままでは認証できません。よく使われる逃げ道は2つで、同じサイトなら Cookie のセッションで認証するか、URL のクエリにトークンを載せるかです。後者はアクセスログや履歴に残るので、有効期限のごく短い使い捨てのトークンに限るのが定石です。
LLM のストリーミングでは fetch で読む
長らく地味だった SSE を表舞台に戻したのが、LLM のストリーミング応答です。応答の完成には数秒〜数十秒かかるため、生成されたそばから画面に流し込む体験が標準になりました。OpenAI や Anthropic の API は、この配信形式に text/event-stream を採用しています。サーバからの一方向・テキスト・順序どおり、という SSE の性質が、生成された文字列を逐次届ける要件にぴったり合ったからです。
ただし、ここで使われるのは SSE の「形式」で、EventSource ではないことが多い点に注意が要ります。LLM の API はプロンプトを本文に入れた POST で呼び、認証のヘッダも必要ですが、EventSource は GET しか送れず、ヘッダも付けられません。そこで fetch で POST を送り、返ってきた本文を ReadableStream として少しずつ読みながら、data: の行を自分で切り分けるのが実際の書き方です。この場合、自動再接続や Last-Event-ID は付いてこないので、切れたときの扱いは自前で決めます。
落とし穴 — 接続数とバッファリング
古典的な罠が、ブラウザの同時接続数の制限です。HTTP/1.1 ではブラウザは同じホストに6本程度しか TCP 接続を張れず、SSE は1本を占有し続けるので、タブを複数開くとすぐ上限に達し、同じサイトへのほかのリクエストまで詰まってしまいます。HTTP/2 では1本の接続の上に多数のストリームを重ねられるので、この問題はほぼ解消しました。SSE を本番で使うなら HTTP/2 以降が前提です。
もうひとつの定番は、経路の途中のロードバランサやリバースプロキシが、レスポンスをためてからまとめて流すバッファリングです。こうなるとイベントが届かず、まとめて遅れて届きます。バッファリングを無効にする設定(nginx なら X-Accel-Buffering: no など)と、無通信のまま接続が切られないよう数十秒ごとに送るコメント行(: keepalive)がお約束です。また、つなぎっぱなしの接続はサーバの資源を占有し続けるので、同時接続数に見合ったイベント駆動の実装や、ロードバランサのタイムアウトの設定も見直します。
試問
サーバでは不定期にイベント(新着通知)が起き、ブラウザがそれを受け取ります。受け取り方は、ブラウザが定期的に聞きに行くポーリングと、接続を開いたままサーバが流す SSE(EventSource)から選べ、SSE ではサーバがイベントに通し番号(id)を付けるかどうかも選べます。回線のスイッチで回線を切ったりつないだりでき、下の帯で、イベントが起きた時刻と届いた時刻(取りこぼしは朱の ✕)が見えます。
サーバでは不定期にイベント(新着通知)が起きています。いまは3秒ごとのポーリング(ブラウザが定期的に「何か変わった?」と聞く)です。受け取り方・送り方・回線を切り替えて、下の帯で起きた時刻と届いた時刻を見比べてください。
