SSE(Server-Sent Events)とWebSocketの違いは、ひと言でいえば「サーバーから流すだけのHTTPレスポンス」か「双方向に使える別プロトコルの接続」かです。通知やAIチャットの文字の逐次表示のように、サーバーから送るだけならSSEで足ります。チャットの送信やゲームの操作のように、クライアントからも高頻度に送るならWebSocketが選択肢です。本記事は、この2つの差を仕様とコードで示し、1件あたりの枠のバイト数と接続確立の往復回数で性能を比べます。そのうえで、よく比べられるWebRTCとWebSocketの違い(実際には併用する関係)、HTTP/3上のWebTransport、サーバーレス環境で常時接続にかかる上限と課金まで判断基準を整理します。
まとめ:SSEとWebSocketの違いと選び方の結論
| 技術 | 通信方向 | 下位の通信 | 向く用途 |
|---|---|---|---|
| SSE | サーバー→クライアント | HTTP(TCPまたはQUIC) | 通知・進捗・LLMの逐次出力 |
| WebSocket | 双方向 | TCP/HTTP/3ではQUIC(拡張CONNECT) | チャット・共同編集・対戦ゲーム |
| WebRTC | 双方向(P2P) | UDP(DTLS・SRTP・SCTP) | 音声・映像通話、画面共有 |
| WebTransport | 双方向 | HTTP/3(QUIC・UDP) | 低遅延で順序を問わないデータ |
- クライアントから送るのが「たまのリクエスト」だけなら、通常のHTTPリクエスト+SSEで組める。WebSocketは要らない。
- SSEはブラウザの
EventSourceが自動再接続と最終イベントID(Last-Event-ID)の通知まで行う。取りこぼし分の保存・再送はサーバー側で書く。WebSocketは再接続から自前で書く。 - 1件ごとの枠はWebSocketが2〜14バイト、SSEは
idとeventを付けた例で27バイト。この差が通信量に効くのは小さなメッセージを大量に送る場面で、1秒に数件の通知なら無視できる。 - SSEの弱点は、HTTP/1.1での同時接続6本の上限、
EventSourceがGET専用でヘッダーを付けられないこと、テキスト専用であること。前の2つはHTTP/2とfetchで回避できる。 - 音声・映像はWebRTC。WebRTCはブラウザ同士を直接つなぐが、つなぐ前の情報交換(シグナリング)は仕様の外なので、そこにWebSocketを使うのが定番。WebTransportは2026年3月にSafari 26.4が対応して主要ブラウザがそろったばかりなので、WebSocketの代替として採るなら縮退経路を残す。
- サーバーレスでは「接続が開いている時間に課金・切断されるか」で選ぶ。Vercel Functionsは最大実行時間(既定300秒)でSSEもWebSocketも切れる。Cloudflare Durable Objectsは接続中ずっと課金され、休止できるのはHibernation APIで受けたWebSocketだけ。
本番で詰まりやすいのは、プロトコルの選択よりも中継機器の設定です。nginxのバッファリングとALBの60秒のアイドルタイムアウトは、SSEでもWebSocketでも最初に確認してください。
SSEとWebSocketの違い|仕様で比べる7項目と接続の仕組み
SSEはWHATWGのHTML Living Standard(9.2 Server-sent events)で定義された仕組みで、ブラウザ側のAPIは EventSource、サーバーは Content-Type: text/event-stream のレスポンスを閉じずに書き足していきます。WebSocketはRFC 6455で定義された独立したプロトコルで、HTTPのリクエストで始まり、HTTP/1.1ではサーバーの101応答後にWebSocketフレームへ切り替わります。HTTP/2・HTTP/3での通信経路は、拡張CONNECTのストリーム上です。
| 比較項目 | SSE | WebSocket |
|---|---|---|
| 通信方向 | サーバー→クライアントのみ | 双方向 |
| 接続の正体 | 継続するHTTPレスポンス | Upgradeまたは拡張CONNECT上のフレーム |
| 送れるデータ | UTF-8テキストのみ | テキストとバイナリ |
| 自動再接続 | あり(retry で間隔指定) |
なし(自前実装) |
| 取りこぼし分の再送 | ID通知は標準、保存・再送は実装が必要 | アプリで設計 |
| リクエストヘッダー | 任意指定不可(Cookieは条件付き) | 任意指定不可(サブプロトコル指定可) |
| HTTP/2・HTTP/3 | そのまま多重化される | RFC 8441・9220の拡張が必要 |
接続確立方式:HTTPレスポンスとUpgrade・拡張CONNECTの違い
SSEはただのHTTPレスポンスなので、認証・ログ・WAF・CDNなど既存のHTTP基盤がそのまま効きます。WebSocketは Upgrade: websocket ヘッダーで接続を乗り換えるため、途中のプロキシやロードバランサーには、アップグレードを通す設定が必要です。HTTP/2上でWebSocketを張るにはRFC 8441(2018年)のExtended CONNECT、HTTP/3ではRFC 9220(2022年)が必要です。ブラウザ・ロードバランサー・サーバーのどこかが拡張CONNECTに対応していなければ、WebSocketはHTTP/1.1の別接続で張られます。WebSocketだけ別経路になる前提で、タイムアウトや台数の設計を分けておくのが安全です。
再接続と取りこぼし:Last-Event-IDは標準、WebSocketは自前で設計
SSEでは、サーバーが各イベントに id: を付けておくと、切断後にブラウザが自動で再接続し、最後に受け取ったIDを Last-Event-ID リクエストヘッダーで送ります。WHATWGの処理モデルにも、再接続のリクエストへこのヘッダーを載せる手順が書かれています。サーバーはその続きから送り直せばよく、再接続の間隔も retry: フィールド(ミリ秒)で指示することが可能です。WebSocketは切れたら close イベントが来るだけで、再接続の待ち時間、再送すべきメッセージの特定、重複の排除はアプリで設計します。Socket.IOのようなライブラリが使われる理由の多くはここにあります。
ポーリング・ロングポーリングの比較:許容遅延と問い合わせ頻度
SSEとWebSocketを比べる前に、ポーリングで足りないかを確かめる価値があります。HTTPの双方向利用を整理したRFC 6202は、クライアントが定期的に問い合わせ、データが無ければ空の応答が返る方式を「ショートポーリング」、サーバーがイベントが出るまで応答を保留する方式を「ロングポーリング」、応答を閉じずに書き足し続ける方式を「HTTPストリーミング」と呼び分けています。SSEはこのHTTPストリーミングをブラウザのAPIとして標準化したものです。
| 方式 | 届くまでの遅れ | イベントが無いときの通信 | 向く場面 |
|---|---|---|---|
| ショートポーリング | 最大で問い合わせ間隔 | 間隔ごとに空の往復 | 数十秒遅れてよい集計表示 |
| ロングポーリング | ほぼ即時 | 保留中の要求1本 | SSEが通らない古い中継機器 |
| SSE | 即時 | ハートビートのみ | 通知・進捗・逐次出力 |
| WebSocket | 即時(双方向) | ハートビートのみ | 送信も高頻度な機能 |
RFC 6202は、許容できる遅れが数秒程度まで短いと、ショートポーリングの頻度がサーバーとネットワークの負担になると述べています。5秒間隔なら1利用者あたり1時間に720回問い合わせます。更新がまれな用途では、その多くが新しいデータのない応答になります。逆に1分おきの更新で足りる画面なら、中継機器やサーバーレスの時間上限と無縁のポーリングが最も壊れにくい方式です。
SSE・WebSocket・WebTransportの性能比較|枠のバイト数と往復回数
「どちらが速いか」は、1件ごとに付く枠(フレーミング)の大きさと、最初のデータが届くまでの往復回数に分けると比べられます。ここではサーバー資源と回線条件を同一と仮定し、仕様から計算できる枠の大きさと接続確立の往復回数に比較対象を絞ります。
フレーミングの計算例:WebSocketのヘッダーとSSEのフィールド
WebSocketのフレームヘッダーは、RFC 6455の5.2節で本文長に応じて2・4・10バイトと決まっており、クライアント→サーバー方向はマスク用に4バイトが加わります(最大14バイト)。メッセージを複数のフレームに分けて送ると、フレームごとに付きます。SSEはフィールド名・コロン・改行がテキストで乗り、この例の data: x\n\n は本文1バイトに8バイトが付きますが、任意の空白を省いた data:x\n\n なら7バイトです。以下のコードの「SSE最小」は、空白付きでid・eventを省略した形式を指します。次のPythonは、両者の枠を仕様どおりに数える計算です。標準ライブラリだけで動きます。
# overhead.py:1メッセージあたりの枠のバイト数を仕様どおりに数える(python overhead.py)
import json
def ws_header(n, from_client):
# RFC 6455 5.2:本文長で 2 / 4 / 10 バイト、クライアント送信はマスク 4 バイト加算
base = 2 if n <= 125 else 4 if n <= 65535 else 10
return base + (4 if from_client else 0)
def sse_frame(payload, event_id=None, event=None):
# WHATWG HTML 9.2.5:フィールド名・コロン・空白・改行はすべてテキストで乗る
s = ''
if event_id is not None:
s += f'id: {event_id}\n'
if event is not None:
s += f'event: {event}\n'
s += f'data: {payload}\n\n'
return len(s.encode('utf-8'))
H2_DATA = 9 # RFC 9113 4.1:HTTP/2 フレームヘッダーは 9 バイト固定
for body in ['x', json.dumps({'id': 8, 'price': 108}, separators=(',', ':')), 'a' * 1000]:
n = len(body.encode('utf-8'))
sse_min = sse_frame(body) - n
sse_full = sse_frame(body, 8, 'price') - n
print(f'本文{n:>5}B | SSE最小 +{sse_min}B / id+event付き +{sse_full}B'
f'(HTTP/2なら +{H2_DATA}B)| WS 受信 +{ws_header(n, False)}B / 送信 +{ws_header(n, True)}B')
$ python overhead.py
本文 1B | SSE最小 +8B / id+event付き +27B(HTTP/2なら +9B)| WS 受信 +2B / 送信 +6B
本文 20B | SSE最小 +8B / id+event付き +27B(HTTP/2なら +9B)| WS 受信 +2B / 送信 +6B
本文 1000B | SSE最小 +8B / id+event付き +27B(HTTP/2なら +9B)| WS 受信 +4B / 送信 +8B
20バイトのJSONを流すと、SSE(id・event 付き)は27バイト、WebSocketは2バイトの枠が付きます。HTTP/2ではRFC 9113が定めるDATAフレームのヘッダーは1フレームにつき9バイトです。掲載計算は1イベントを1つのDATAフレームに収める仮定で、実際の追加量は分割・集約によって変わります(HTTP/2上のWebSocketも同様)。1秒に数回の通知ならこの差は無視できます。1秒に数十〜数百件の小さなメッセージを双方向に送る対戦ゲームや共同編集では、WebSocketのほうが通信の無駄を抑えられる方式です。逆に1KBを超える本文では、枠の差は数%以下に縮みます。
最初のデータが届くまでの往復回数:TCP・TLS・QUICの違い
新しく接続を張る場合、TCPの3ウェイハンドシェイクに1往復、TLS 1.3に1往復が掛かります。QUIC(RFC 9000)はトランスポートと暗号の確立を1往復にまとめるので、HTTP/3上の通信はここで1往復短くなります。
| 方式 | 新規接続から最初のデータまで | 既存の接続を再利用する場合 |
|---|---|---|
| SSE(HTTP/1.1・2) | TCP+TLS+リクエストで約3往復 | リクエストの1往復 |
| SSE(HTTP/3) | QUIC+リクエストで約2往復 | リクエストの1往復 |
| WebSocket(HTTP/1.1) | TCP+TLS+Upgradeで約3往復 | 再利用不可(専用接続) |
| WebSocket(RFC 8441) | HTTP/2の接続確立+CONNECT | 拡張CONNECTの1往復 |
| WebTransport | QUIC+CONNECTで約2往復 | CONNECTの1往復 |
| WebRTC | シグナリング+ICE+DTLSで数往復以上 | 同じピア間なら維持して使う |
往復回数が効くのは接続を張る瞬間だけで、張った後の1件あたりの遅延はどの方式もほぼ片道の伝送時間です。TCPベースのSSEとWebSocketは、どちらもパケット損失時に後続データが再送を待ちます。この待ちは両者に共通する制約で、両者の性能差を直接説明するものではありません。ページを開くたびに配信を始めるダッシュボードなら、すでにページ本体で張ったHTTP/2の接続に相乗りできるSSEのほうが、最初の1件は早く届きます。
Node.js実装例:SSEの価格配信とWebSocketのエコー通信
次のコードは、Node.js 22以降(2026年10月14日時点の最新は26.12.0、LTSは24系)と、WebSocketライブラリwsの8.22系を前提に、1台のHTTPサーバーに両方の口を作る例です。SSEは標準の http モジュールだけで書け、WebSocketのサーバーはライブラリを使います。
// server.mjs(npm i ws の後、node server.mjs で起動)
import http from 'node:http';
import { WebSocketServer } from 'ws';
const server = http.createServer((req, res) => {
if (req.url === '/events') {
const lastId = Number(req.headers['last-event-id'] ?? 0);
if (!Number.isSafeInteger(lastId) || lastId < 0) {
res.writeHead(400).end('Invalid Last-Event-ID');
return;
}
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
let id = lastId;
const timer = setInterval(() => {
id += 1;
res.write(`id: ${id}\nevent: price\ndata: ${JSON.stringify({ id, price: 100 + id })}\n\n`);
if (id % 3 === 0) res.end(); // 3件ごとに切断して再接続を試す
}, 200);
const ping = setInterval(() => res.write(': ping\n\n'), 15000);
req.on('close', () => { clearInterval(timer); clearInterval(ping); });
return;
}
res.writeHead(404).end();
});
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
ws.on('message', (data, isBinary) => {
ws.send(isBinary ? data : `echo: ${data}`, { binary: isBinary });
});
});
server.listen(8080);
curl -N でSSEの口を叩くと、ストリームはただのテキストです。Last-Event-ID: 7 を付けて再接続すると、IDは8から再開します。この例は値をIDから計算しているだけなので、本番では送信済みイベントを保存しておき、受け取ったIDより後を送り直す処理が要ります。
$ curl -sN -H 'Last-Event-ID: 7' http://localhost:8080/events
retry: 3000
id: 8
event: price
data: {"id":8,"price":108}
id: 9
event: price
data: {"id":9,"price":109}
ブラウザ側は、SSEなら new EventSource('/events') に addEventListener('price', …) を付けるだけで、切断後の再接続とIDの引き継ぎはブラウザが行います。WebSocketは送受信の口が1本で、切断時の扱いは自分で書きます。Node.jsの WebSocket クライアントは公式ドキュメントのとおり22.0.0でフラグ不要になり、22.4.0で実験扱いを外れました。次のコードは追加のライブラリ無しで動きます。
// wsclient.mjs
const ws = new WebSocket('ws://localhost:8080/ws');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
ws.send('hello');
ws.send(new Uint8Array([1, 2, 3]));
};
ws.onmessage = (e) => {
console.log(typeof e.data === 'string' ? e.data : new Uint8Array(e.data));
if (e.data instanceof ArrayBuffer) ws.close(1000, 'done');
};
ws.onclose = (e) => console.log('closed', e.code, e.reason);
// 期待される出力: echo: hello、Uint8Array(3) [ 1, 2, 3 ]、closed 1000 done
このWebSocketクライアントには、再接続の処理がまだ1行も入っていません。EventSource なら肩代わりされる部分で、後述のコードがそのまま保守対象に加わります。
SSEのデメリットと回避策|接続数・ヘッダー・中継機器の3つの壁
SSEの弱点として語られるものは、HTTP/1.1時代の制約と、EventSource というAPIの制約が混ざっています。仕組みそのものの弱点は「一方向でテキスト専用」の1点だけです。
HTTP/1.1の同時接続6本の制限とHTTP/2による多重化
MDNのEventSourceのページは、HTTP/2を使わない場合、SSEの接続数はブラウザ×ドメインごとに6本までで、ChromeとFirefoxはこの問題を「Won’t fix」としていると説明しています。タブを7枚開くと7枚目の接続が待たされます。HTTP/2では同時ストリーム数の上限(SETTINGS_MAX_CONCURRENT_STREAMS)を各側が通知し、RFC 9113上の初期値は無制限で100以上が推奨です。HTTPS+HTTP/2ではHTTP/1.1の6接続制限を回避できますが、サーバーが通知する同時ストリーム数の上限は確認が必要です。開発環境の素のHTTP/1.1で再現して誤解しやすい点です。
EventSourceのGET専用・ヘッダー不可の制約とfetchによる回避
EventSource のコンストラクタが受け取るのはURLと withCredentials だけです。Authorization ヘッダーも、POSTの本文も付けられません。Bearerトークンで守るAPIや、プロンプトを本文で送るLLMのAPIには合いません。OpenAIのAPIは stream: true でSSE形式の応答を返しますが、公式のNode.js SDK(openai-node)は EventSource を使わず、POSTしたレスポンス本文を自前の解析処理(Stream.fromSSEResponse)で読んでいます。
回避策は、fetch で普通にリクエストを送り、レスポンス本文をストリームとして読むことです。空行(\n\n)でイベントを区切り、data: 行を取り出します。次のコードは上のサーバーが返すLF改行・JSONデータ専用の簡易版です。CRLF改行や複数行・非JSONのデータも受けるなら、仕様の解析手順に沿ったパーサー(npmのeventsource-parser、2026年10月時点で4.1系)を使ってください。
// fetchsse.mjs:ヘッダー付きでSSEを読む
const res = await fetch('http://localhost:8080/events', {
headers: { Authorization: 'Bearer dummy' },
});
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, i);
buf = buf.slice(i + 2);
const data = block.split('\n')
.filter((l) => l.startsWith('data:'))
.map((l) => l.slice(5).trimStart())
.join('\n');
if (data) console.log(JSON.parse(data));
}
}
// 期待される出力: { id: 1, price: 101 } … { id: 3, price: 103 }
代わりに失うのは自動再接続と Last-Event-ID の自動送信です。LLMの応答のように1回のリクエストで完結するストリームなら再接続は要らないので、この割り切りで十分です。
SSEの配信遅延・切断:プロキシのバッファリングとアイドルタイムアウト
SSEがローカルでは流れるのに本番で届かない場合は、中継機器のレスポンスバッファリングを確認します。nginxのproxyモジュールは proxy_buffering が既定でonなので、SSEの口では proxy_buffering off; にするか、上のコードのようにレスポンスに X-Accel-Buffering: no を付けます。
もう1つはアイドルタイムアウトです。nginxの proxy_read_timeout とAWSのALBのアイドルタイムアウトは、どちらも既定60秒です。WHATWGの仕様は、コロンで始まるコメント行(: ping)を15秒ほどごとに送る方法を挙げており、上のサーバーコードの ping がそれに当たります。WebSocketも同じです。
nginx・ALB配下の設定例|SSEとWebSocketを同じドメインで通す手順
SSEとWebSocketは、同じオリジンの別パスで受けると、認証処理を共通化しやすくなります。ただし、SSEのCORSとWebSocketのOrigin検証は別に扱います。リバースプロキシの仕組みと導入判断で扱うnginxを前段に置く場合、パスごとに必要な設定は違います。
nginxの設定:SSEはバッファ無効、WebSocketはUpgradeの中継
WebSocketはhop-by-hopヘッダーの Upgrade と Connection を明示的に引き継がないと、nginxで止まります。nginxのWebSocket proxyingの解説にある map の書き方を使うと、通常のリクエストとアップグレードを同じ設定で扱えます。
# /etc/nginx/conf.d/realtime.conf(server ブロックの中は抜粋)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream app {
server 127.0.0.1:8080;
keepalive 32;
}
server {
listen 443 ssl;
http2 on;
server_name example.com;
# SSE:バッファを切り、イベントが来ない時間より長いタイムアウトにする
location /events {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
# WebSocket:Upgrade と Connection を中継する
location /ws {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 1h;
}
}
設定を変えたら nginx -t で文法を確かめ、nginx -s reload で反映します。SSEの口で Connection '' を指定しているのは、上流とのkeepaliveを効かせるためです。proxy_read_timeout 1h は上流から何も届かない時間の上限なので、15秒ごとのハートビートでこのアイドルタイムアウトは避けられますが、サーバー再起動、通信障害、別の中継機器の時間上限などによる切断は残ります。
ALB・API Gateway・Cloudflareの時間上限と再接続前提の設計
AWSのALBは、ロードバランサー属性のドキュメントのとおりアイドルタイムアウトが既定60秒です。HTTP/2のPINGフレームはこのタイマーを戻さないと明記されているため、ハートビートはデータとして送ります。API GatewayのWebSocket APIのクォータは、アイドル10分・接続の最大2時間・フレーム32KB・メッセージ128KBです。CloudflareもWebSocketのドキュメントで、無通信の接続を閉じると明記し、クライアント側からのハートビートを勧めています。
上限の値はサービスごとに違いますが、結論は同じです。長時間の接続は「いつか切れる」前提で、クライアントが張り直し、どこから受け直すかをサーバーに伝える設計にします。ALBやNLBの振り分け方式の違いはロードバランサーの仕組みと振り分け方式を参照してください。
サーバーレス・エッジでの選び方|VercelとCloudflareで常時接続にかかる上限と課金
常駐サーバーなら、開いたままの接続はメモリを少し使うだけです。サーバーレスでは「接続が開いている時間」に上限や課金が付くことがあり、SSEとWebSocketの損得が通信方向とは別の理由で入れ替わります。
Vercel Functionsの常時接続:最大実行時間と課金
Vercel Functionsの制限では、最大実行時間にストリームで返す応答の時間も含まれ、Fluid compute有効時の既定はHobby・Pro・Enterpriseとも300秒、上限はHobbyが300秒、Pro・Enterpriseが800秒(1,800秒はベータ)です。WebSocketも2026年6月22日にパブリックベータで受けられるようになりましたが、Vercelのガイドは、接続は最大実行時間で閉じるのですべてのクライアントに再接続の処理が要ると明記しています。Active CPUはコード実行中に計上され、I/O待機中には計上されません。ただし、Provisioned Memoryは処理中のリクエストが残る間も計上されるため、待機中の接続が無課金になるわけではありません。
Vercelでは、どちらも既定で5分で切れる前提になります。張り直しと Last-Event-ID の送信をブラウザが行うSSEのほうが、この前提に合わせやすい方式です。数十秒で終わるLLMの応答なら上限は問題になりません。
Cloudflare Workers・Durable Objectsの常時接続:課金とWebSocketの休止条件
Workersの料金はリクエスト数とCPU時間に掛かり、Standardプランでは実行時間(Duration)に課金も上限もありません。通常のWorkerから1本のSSEを返すだけなら、開いている時間は課金されません。
話が変わるのは、複数の接続へ同じイベントを配るためにDurable Objectで接続を束ねる構成です。Durable Objectsの料金は、オブジェクトが動いているか、休止の条件を満たさずにメモリに残っている間の実時間に掛かります。ライフサイクルの説明によれば、休止できるのは、処理中のリクエスト・setInterval・標準のWebSocket APIのいずれも残っていないときだけです。SSEの応答をハートビート付きで書き続けるオブジェクトはこの条件を満たせず、accept() で受けたWebSocketも同じく接続中ずっと課金されます。Hibernation WebSocket API(ctx.acceptWebSocket())で受けたWebSocketだけは、接続を保ったままオブジェクトをメモリから退避でき、その間の実行時間は課金されません。
公式の料金例では、休止を使わず100オブジェクトが1日8時間動き続ける例の実行時間の料金が月137.50ドル、Hibernationを使い1分に1通のメッセージを処理する時間だけ動く例は月12.50ドルです(接続数と稼働時間の前提は例ごとに異なります)。メッセージがまばらな通知をDurable Objectで配るなら、一方向の配信でもWebSocket(Hibernation)を選ぶ理由になります。そのぶん、休止で消えるメモリ上の状態を接続ごとに保存し直す処理(serializeAttachment)が増えます。
WebSocketを選ぶ条件と運用で増える作業|分散・上限・再接続
WebSocketを選ぶべきなのは、クライアントからの送信が高頻度で、かつ往復の遅延が体感に効く場合です。チャットの入力中表示、共同編集のカーソル同期、対戦ゲームの操作がこれに当たります。バイナリを低いオーバーヘッドで送りたい場合もWebSocketです。
その代わり、運用では次の作業が増えます。
- 水平分散:接続が特定のサーバーに張り付くので、別サーバーの利用者へ届けるにはRedisのPub/Subなどでサーバー間を中継する(SSEも同じ)。再送用にイベントをIDつきで残すなら、ValkeyとRedisのStreamsのようにIDで範囲を読める構造が合う。
- 送信の詰まり:ブラウザの
WebSocketAPIには受信側の処理が追いつかないときに送信を止める仕組み(バックプレッシャー)が無い。対応するWebSocketStreamはChrome 124以降などのChromium系ブラウザに限られ、MDNでは実験的かつ非標準の扱いで、FirefoxとSafariは未対応にとどまる。
再接続の実装:指数バックオフとジッター、イベントの再開位置の受け渡し
サーバーが落ちて全員が同時に切れたとき、全クライアントが同じ間隔で張り直すと、復旧直後のサーバーに接続が殺到します。待ち時間を倍々に伸ばし(指数バックオフ)、さらに乱数で散らす(ジッター)のが定石です。次のコードは、最後に受けたIDをクエリで渡し、サーバーがそれより後を送り直す前提のクライアントです。
// reconnect.mjs:指数バックオフ+フルジッターで張り直し、受信済みIDを渡す
let lastId = 0;
let attempt = 0;
function connect() {
const ws = new WebSocket(`ws://localhost:8080/ws?since=${lastId}`);
ws.onopen = () => {
const stableTimer = setTimeout(() => { attempt = 0; }, 10000);
ws.addEventListener('close', () => clearTimeout(stableTimer), { once: true });
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.id <= lastId) return; // 再送で重なった分は捨てる
lastId = msg.id;
console.log(msg);
};
ws.onclose = (e) => {
if (e.code === 1000) return; // 正常終了は張り直さない
const cap = Math.min(30000, 500 * 2 ** attempt); // 0.5秒から倍々、上限30秒
attempt += 1;
setTimeout(connect, Math.random() * cap);
};
}
connect();
SSEの EventSource は、この「張り直し」と「IDの受け渡し」をブラウザが行います。WebSocketでは上のおよそ20行と、サーバー側の since の解釈・イベント保存が、機能ごとに保守対象として残ります。
ハンドシェイク、フレーム構造、Origin検証まで含めた実装はWebSocketの仕組みとNode.jsでの実装・運用判断で扱っています。再接続やルームを自前で書きたくない場合は、WebSocketの上に独自プロトコルを載せたSocket.IOの仕組みとWebSocketとの違いも候補になります。
WebRTCとWebSocketの違い|P2Pとサーバー経由、シグナリングでの併用
SSEをHTTP/1.1・HTTP/2で、WebSocketをTCPベースの接続で使う場合は、1つのパケットが失われると後続のデータが再送を待たされます(ヘッドオブラインブロッキング)。映像や、最新の値だけが意味を持つゲームの位置情報では、この待ちが遅延の主因になります。これを避けるのがUDP系のWebRTCとWebTransportです。
WebRTCはブラウザ同士を直接つなぎ、音声・映像をSRTPで、任意のデータをRTCDataChannelで送ります。データチャネルはRFC 8831のとおりDTLS上のSCTPをUDPで運ぶ方式で、ordered: false や maxRetransmits: 0 を指定すると、順序や再送を諦めて遅延を優先できます。
| 比較項目 | WebSocket | WebRTC |
|---|---|---|
| つなぐ相手 | クライアントとサーバー | ブラウザ同士(P2P) |
| 下位の通信 | TCP/HTTP/3ではQUIC | 主にUDP(TCP経由も可能) |
| 運ぶもの | テキスト・バイナリ | 音声・映像・任意データ |
| 接続の準備 | URLへ接続するだけ | シグナリング+ICE |
| NAT越え | 不要(サーバーへ接続) | STUN・TURNが必要 |
| サーバーの役割 | 全メッセージを中継 | 接続の仲介(P2Pが通らなければTURNで中継) |
WebRTCのシグナリングをWebSocketで中継する最小コード
WebRTCは、相手の映像形式や暗号鍵の情報(SDPのoffer・answer)とネットワーク上の経路候補(ICE候補)を交換しないとつながりません。RFC 8829(JSEP)は、この交換の手段について特定のシグナリングの方式を定めず、アプリに任せると書いています。そこで、すでにサーバーとの間に双方向の口を持つWebSocketが使われます。次のコードは、同じルームの相手へメッセージを中身を解釈せずに渡すだけの中継サーバーと、2つのクライアントでの受け渡しの確認です(Node.js v26.5.0+ws 8.22.0で実行)。
// signal.mjs:WebRTCのシグナリングをWebSocketで中継する最小例(node signal.mjs)
import { WebSocketServer } from 'ws';
const rooms = new Map(); // ルーム名 → 参加中の接続
const wss = new WebSocketServer({ port: 8081 });
wss.on('connection', (ws, req) => {
const room = new URL(req.url, 'http://x').searchParams.get('room') ?? 'default';
if (!rooms.has(room)) rooms.set(room, new Set());
const peers = rooms.get(room);
peers.add(ws);
// offer・answer・ICE候補を、中身を解釈せず同じルームの相手へ渡すだけ
ws.on('message', (data) => {
for (const p of peers) if (p !== ws && p.readyState === 1) p.send(data.toString());
});
ws.on('close', () => {
peers.delete(ws);
if (peers.size === 0) rooms.delete(room);
});
});
// peers.mjs:2つのクライアントでoffer→answer→ICE候補の受け渡しを試す
const open = (name) => new Promise((ok) => {
const ws = new WebSocket('ws://localhost:8081/?room=r1');
ws.onmessage = (e) => console.log(`${name} が受信:`, JSON.parse(e.data).type);
ws.onopen = () => ok(ws);
});
const a = await open('A');
const b = await open('B');
a.send(JSON.stringify({ type: 'offer', sdp: 'v=0 ...' }));
b.send(JSON.stringify({ type: 'answer', sdp: 'v=0 ...' }));
a.send(JSON.stringify({ type: 'candidate', candidate: 'candidate:1 1 udp ...' }));
setTimeout(() => { a.close(); b.close(); }, 300);
$ node peers.mjs
B が受信: offer
B が受信: candidate
A が受信: answer
ブラウザでは、この sdp の部分に RTCPeerConnection の createOffer() などが作った値が入ります。接続が確立すれば、音声・映像はWebSocketを通らず、ブラウザ同士の経路(P2Pが通らない環境ではTURNサーバーの中継)を流れます。本番ではルームへの参加者の認証と人数の上限を足してください。
「WebRTCとWebSocketのどちらを使うか」は多くの場合、二択になりません。音声・映像はWebRTC、チャットの文字やシグナリングはWebSocket(またはSSE+POST)という併用が基本形です。WebRTCを単なるデータ通信に使うのは、サーバーを経由させたくないP2Pのファイル転送や、順序を捨ててでも遅延を詰めたい場合に限られます。3人以上の通話でのSFUの選定やTURNの運用はWebRTCのP2Pの仕組みとICE・STUN・TURN、SFUの選定で扱っています。
WebTransportとWebSocketの違い|HTTP/3上の複数ストリームと縮退の条件
WebTransportは、QUICとHTTP/3の仕組みの上で、信頼性のあるストリームを複数本と、再送しないデータグラムを1本の接続で扱えるクライアントサーバー型のAPIです。仕様はW3CのWebTransportで策定が進んでいます。WebSocketの「1本の順序付き通信」と違い、あるストリームの欠落データを待つ間も別のストリームを配送できます。ただし、接続全体の輻輳制御などによる影響は共有します。
ブラウザ対応は、Chrome・Edge 97、Firefox 114に続き、2026年3月のSafari 26.4で主要ブラウザがそろい、MDNでもBaseline 2026(Newly available)の表示になりました。一方で、企業ネットワークではUDP 443が塞がれていることがあり、ロードバランサーやCDNの対応もTCPほど整っていません。WebSocketの代わりに採るなら、つながらないときにWebSocketへ戻る経路を必ず残してください。サーバー実装と縮退設計はWebTransportの仕組みと実装・採用判断にまとめています。
用途別の選定表|LLMストリーミング・通知・チャット・ゲームの第一候補
選定で迷ったら、「クライアントからの送信頻度」と「順序と再送が必要か」の2軸で決めます。
| 用途 | 第一候補 | 判断の理由 |
|---|---|---|
| LLMの応答の逐次表示 | SSE(fetchで読む) | 1回のPOSTに対する一方向の流れ |
| 通知・進捗バー・ダッシュボード | SSE | EventSourceが再接続・ID通知に対応 |
| チャット | WebSocket | 送信も受信も高頻度 |
| 共同編集・ホワイトボード | WebSocket | 小さな双方向メッセージが多い |
| ビデオ通話・画面共有 | WebRTC | メディアの低遅延とP2P |
| クラウドゲーミング・位置同期 | WebTransport | 古い値の再送を待たない |
| ツール呼び出しが多いAIエージェント | WebSocket | 接続を保ち差分の入力だけを送る |
| 音声対話AI | WebRTCまたはWebSocket | 提供元の対応方式に合わせる |
避けたいのは、「リアルタイムだからWebSocket」と決め打ちすることです。サーバーから流すだけの機能にWebSocketを使うと、EventSource なら不要な再接続処理を自前で抱えます。イベントの保存・再送やサーバー間の配信連携は、どちらを選んでも設計が必要です。逆に、SSEで作った通知に後から「既読の送信」が加わっただけなら、既読は通常のPOSTで送ればよく、WebSocketへ移る理由にはなりません。
AIエージェントと外部ツールをつなぐMCPも、常時接続のHTTP+SSE方式から、POSTへの応答をSSEで返すStreamable HTTPへ移っています(Streamable HTTPとMCPのSSEとの違い)。
業務システムの通知や進捗表示、社内チャット、AIの逐次応答を既存のWebアプリに組み込む場面では、画面ごとの送受信頻度に加え、バイナリの有無、再送要件、利用する配信基盤を整理すると、方式の候補を絞れます。要件の整理からプロトコル選定・実装・中継機器の設定まで外部に任せたい場合は、一創の業務用・Webアプリ開発でご相談を受け付けています。
よくある質問
SSEとWebSocketの違いは何ですか?
SSEはサーバーからクライアントへ一方向に送る、終わらないHTTPレスポンスです。WebSocketはHTTPのリクエストから始めて張る双方向の接続で、バイナリも送れます。SSEはEventSourceが自動再接続と最終イベントIDの通知を行い、WebSocketは再接続から自前で書きます。
SSE(Server-Sent Events)のデメリットは?
一方向でテキスト専用であることが本質的な制約です。HTTP/1.1での6本の接続数制限はHTTP/2で、EventSource がGET専用でヘッダーを付けられない点はfetchでレスポンスを読むことで回避できます。
WebRTCとWebSocketの違いは何ですか?
WebSocketはクライアントとサーバーをつなぎ、すべてのメッセージがサーバーを通ります。下位通信は通常TCPですが、HTTP/3上ではQUICです。WebRTCは主にUDPを使って音声・映像を送りますが、直接接続できない場合はTURNによる中継やTCP経由の通信も利用します。WebRTCは接続前の情報交換(シグナリング)の方式を規定していないため、その交換にWebSocketを使う併用が一般的です。
SSEとWebSocketはどちらが速いですか?
仕様から計算できる差は枠の大きさで、20バイトのJSONならSSE(id・event付き)は27バイト、WebSocketは2バイトが付きます。実際の遅延や処理能力の優劣は、接続数・圧縮・実装条件をそろえて測って確かめる必要があります。
ChatGPTのようなAIチャットはSSEとWebSocketのどちらを使いますか?
テキストの逐次表示はSSE形式のストリームが主流です。OpenAIのAPIも stream: true でSSE形式の応答を返します。一方、音声で対話するRealtime APIの接続方式はWebRTC・WebSocket・SIPです。テキストでも、モデルとツールの間を多数往復するエージェント処理向けに、Responses APIにはWebSocketモードがあります。1往復で終わる応答はSSE、往復が多い処理は双方向の接続という、送信頻度による使い分けです。