SSE vs WebSocket 使い分け|EventSource 実装と再接続設計
- サーバー→クライアントの一方向通知(ダッシュボード、ログ tail、株価、進捗バー)なら SSE + EventSource。
- 双方向・低遅延(チャット、共同編集、ゲーム操作)なら WebSocket(ws 等)。
- SSE は通常 HTTP のまま実装でき、自動再接続と Last-Event-ID が標準。
- WebSocket はバイナリ・高頻度双方向に強いが、LB タイムアウトと自前 heartbeat が必須。
- 外部 SaaS からのイベントは WebHook が別レイヤー。
SSE WebSocket 違い — まず押さえる3つの軸
「リアルタイム通信」と一口に言っても、要件は大きく3つに分かれる。
- 通信方向 — サーバー→クライアントだけか、双方向か
- プロトコル — 通常 HTTP の延長か、WebSocket ハンドシェイク後の独立フレームか
- 運用 — プロキシ・LB・再接続・認証の扱い
Server-Sent Events(SSE) は HTML5 標準の サーバー→クライアント一方向 プッシュ。Content-Type: text/event-stream の HTTP レスポンスを開きっぱなしにし、data: 行でイベントを流す。WebSocket は TCP 接続を Upgrade した後、軽量フレームで 双方向 メッセージをやり取りする。
| 観点 | SSE | WebSocket |
|---|---|---|
| 方向 | サーバー → クライアント(一方向) | 双方向 |
| プロトコル | 通常 HTTP(GET) | HTTP Upgrade → ws: / wss: |
| ブラウザ API | EventSource(組み込み) | WebSocket(組み込み) |
| データ形式 | UTF-8 テキスト(JSON 文字列が定番) | テキスト or バイナリ |
| 自動再接続 | EventSource 標準(Last-Event-ID) | 自前実装が必要 |
| プロキシ | HTTP 互換で通りやすい | 一部企業 FW でブロックされうる |
| 同時接続 | タブごとに1本の GET ストリーム | 1接続で双方向 |
本記事では SSE WebSocket 違い を実装視点で整理し、EventSource 実装 と Express / ws の完全なコード、再接続ロジック、HTTP/2 マルチプレクシング の注意点まで踏み込む。
SSE(Server-Sent Events)の仕組み
SSE は WHATWG HTML 仕様 で定義された、テキストベースのイベントストリーム だ。1イベントは次のような行で構成される。
id: 42
event: price-update
data: {"symbol":"AAPL","price":189.12}
retry: 5000
data:— ペイロード(複数行可。行末\n\nでイベント区切り)id:— イベント ID。再接続時にLast-Event-IDとして送られるevent:— イベント種別(省略時はmessage)retry:— 再接続までのミリ秒(サーバーからクライアントへ指示)
クライアントは EventSource で接続する。
const es = new EventSource('/api/events/prices');
es.addEventListener('price-update', (e) => {
const payload = JSON.parse(e.data);
console.log('price', payload);
});
es.onerror = () => {
// EventSource は内部で自動再接続する
console.warn('SSE connection error, reconnecting...');
};
SSE の利点は 「HTTP のまま」 であること。CDN・nginx・Cloudflare のキャッシュルール、CORS、Cookie 認証、既存の mTLS など、HTTP インフラをそのまま流用できる。ロードバランサーは 長寿命 GET として扱う必要があるが、WebSocket ほど特殊な設定を要しないことが多い。
SSE が向くユースケース
- ダッシュボードの KPI 更新 — 30秒ごとに集計結果を push
- ビルド・デプロイの進捗 — CI ログの tail
- 通知ベル — 新着メッセージ件数の増分
- 株価・為替のティッカー — サーバー集約後の quote 配信
- 管理画面の監視アラート — 閾値超過イベント
いずれも クライアントからサーバーへ高頻度送信しない 前提だ。ユーザー操作は通常の REST POST で行い、結果だけ SSE で流す構成がシンプル。
WebSocket の仕組み
WebSocket は HTTP Upgrade で確立する。
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
サーバーが 101 Switching Protocols を返すと、以降は WebSocket フレーム で双方向通信する。HTTP ヘッダーは最初だけ。メッセージはテキスト(JSON)でもバイナリ(Protobuf、音声チャンク)でもよい。
WebSocket が向くユースケース
- チャット — 入力中の typing indicator、既読、メッセージ送受信
- 共同編集 — OT/CRDT による操作の双方向同期
- オンラインゲーム — 低遅延の入力・状態更新
- Collaborative whiteboard — 座標の高頻度送受信
- ブラウザ ↔ サーバー間の RPC 風通信 — リクエスト/レスポンスを同一接続で
双方向かつ メッセージ頻度が高い 場合、SSE では実現できない(クライアント→サーバーは別 HTTP リクエストが必要)。
SSE と WebSocket の比較表
| 観点 | SSE / WebSocket |
|---|---|
| 通信方向 | サーバー → クライアント / 双方向 |
| ブラウザ API | EventSource / WebSocket |
| Node サーバー | Express + res.write() / ws・socket.io |
| メッセージ形式 | UTF-8 テキストのみ / テキスト + バイナリ |
| 自動再接続 | EventSource 標準 + Last-Event-ID / 自前 heartbeat + backoff |
| HTTP/2 | 長寿命 GET ストリームとして multiplex 可能 / Upgrade 後は独立 |
| 認証 | Cookie 同一オリジンが楽 / 初回 Upgrade 時 or トークン |
| 同時接続コスト | 接続1本・軽量 / 接続1本・heartbeat 必須 |
| IE11 | 非対応(polyfill 限界) / polyfill あり |
| 典型レイテンシ | 数百 ms 〜 数秒(バッチ push) / 数十 ms 単位 |
ポーリング・WebHook との位置づけ
リアルタイム更新の選択肢は SSE / WebSocket だけではない。
| 方式 | 特徴 |
|---|---|
| 短周期ポーリング | setInterval + REST。実装最も簡単だが無駄なリクエストと遅延 |
| 長周期ポーリング | ETag / If-Modified-Since で 304 を狙う。[API キャッシュ設計](/blog/api-caching-etag-cache-control-設計/) と相性良 |
| SSE | サーバー push・一方向。EventSource で再接続標準 |
| WebSocket | 双方向・低遅延。インフラ設定と heartbeat が課題 |
| WebHook | サーバー→サーバー POST。SaaS 連携向き。[WebHook 設計](/blog/webhook-設計-ベストプラクティス/) 参照 |
WebHook は Stripe や GitHub がイベントを あなたの API に POST するパターン。ブラウザは関与しない。SSE / WebSocket は ブラウザ(またはモバイルアプリ)がサーバーに接続 し続けるパターン。バックエンドでは WebHook 受信 → 処理 → 接続中の SSE クライアントへ fan-out という3層構成もよくある。
[SaaS] --WebHook POST--> [Your API] --SSE push--> [Browser EventSource]
|
+--WebSocket--> [Browser Chat UI]
Express で SSE エンドポイントを実装する
本番でそのまま拡張できる Express SSE サーバー の完全例を示す。ポイントは次の4つ。
Content-Type: text/event-streamとCache-Control: no-cache- 接続維持 — nginx 等の
proxy_read_timeoutを超えないよう コメント行(: ping\n\n) で heartbeat Last-Event-ID— 再接続時に欠落イベントを再送- 接続終了時のクリーンアップ —
req.on('close')で interval を clear
// sse-server.js
const express = require('express');
const crypto = require('crypto');
const app = express();
const PORT = 3000;
// 簡易イベントストア(本番は Redis Streams / DB を推奨)
const eventLog = [];
const MAX_LOG = 10_000;
let nextId = 1;
function appendEvent(type, payload) {
const event = {
id: String(nextId++),
type,
data: payload,
createdAt: Date.now(),
};
eventLog.push(event);
if (eventLog.length > MAX_LOG) eventLog.shift();
return event;
}
function formatSse(event) {
const lines = [];
lines.push(`id: ${event.id}`);
lines.push(`event: ${event.type}`);
lines.push(`data: ${JSON.stringify(event.data)}`);
lines.push('');
return lines.join('\n') + '\n';
}
function eventsSince(lastEventId) {
const idx = eventLog.findIndex((e) => e.id === lastEventId);
if (idx === -1) return eventLog.slice(-100); // 不明 ID は直近100件
return eventLog.slice(idx + 1);
}
// 接続中クライアント(本番は Redis Pub/Sub で水平スケール)
const clients = new Set();
function broadcast(type, payload) {
const event = appendEvent(type, payload);
const chunk = formatSse(event);
for (const res of clients) {
res.write(chunk);
}
}
app.get('/api/events/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // nginx バッファ無効
res.flushHeaders?.();
const lastEventId = req.headers['last-event-id'];
if (lastEventId) {
for (const event of eventsSince(String(lastEventId))) {
res.write(formatSse(event));
}
}
// 接続確認イベント
res.write(formatSse(appendEvent('connected', { ts: Date.now() })));
clients.add(res);
// 15秒ごとにコメント ping(LB アイドルタイムアウト対策)
const heartbeat = setInterval(() => {
res.write(': ping\n\n');
}, 15_000);
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(res);
});
});
// デモ: 外部からイベントを注入
app.post('/api/events/publish', express.json(), (req, res) => {
const { type = 'message', payload = {} } = req.body;
broadcast(type, payload);
res.json({ ok: true });
});
// デモ: 定期ティッカー
setInterval(() => {
broadcast('price-update', {
symbol: 'AAPL',
price: +(150 + Math.random() * 50).toFixed(2),
ts: Date.now(),
});
}, 3000);
app.listen(PORT, () => {
console.log(`SSE server http://localhost:${PORT}`);
});
nginx リバースプロキシ設定
SSE は バッファリング されるとクライアントに届くまで遅延する。nginx では次を設定する。
location /api/events/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400s;
chunked_transfer_encoding off;
}
Cloudflare 経由の場合、SSE はサポート されるが、無料プランでは 100秒のアイドルタイムアウト がある。heartbeat(上記 : ping)でアイドル状態を避ける。
EventSource 実装 — クライアント側の完全例
EventSource 実装 の要点は、(1) イベント種別ごとのリスナー、(2) 接続状態 UI、(3) 必要なら 手動フォールバック(自動再接続に加え、一定時間イベントが来なければ close() して再生成)だ。
// sse-client.js
class ReliableEventSource {
#url;
#es = null;
#listeners = new Map();
#lastMessageAt = 0;
#watchdogTimer = null;
#closedByUser = false;
constructor(url) {
this.#url = url;
}
connect() {
this.#closedByUser = false;
this.#es = new EventSource(this.#url, { withCredentials: true });
this.#es.onopen = () => {
this.#lastMessageAt = Date.now();
this.#armWatchdog();
};
this.#es.onmessage = (e) => {
this.#lastMessageAt = Date.now();
this.#dispatch('message', e);
};
for (const [eventName, handlers] of this.#listeners) {
if (eventName === 'message') continue;
this.#es.addEventListener(eventName, (e) => {
this.#lastMessageAt = Date.now();
handlers.forEach((fn) => fn(e));
});
}
this.#es.onerror = () => {
// EventSource が内部再接続中。readyState === CONNECTING なら待つ
if (this.#es.readyState === EventSource.CLOSED && !this.#closedByUser) {
this.reconnect();
}
};
}
#armWatchdog() {
clearInterval(this.#watchdogTimer);
// 45秒イベント無し → 接続を張り直す(LB サイレント切断対策)
this.#watchdogTimer = setInterval(() => {
if (Date.now() - this.#lastMessageAt > 45_000) {
console.warn('[SSE] watchdog reconnect');
this.reconnect();
}
}, 10_000);
}
reconnect() {
this.close(false);
setTimeout(() => this.connect(), 1000 + Math.random() * 2000);
}
on(eventName, handler) {
if (!this.#listeners.has(eventName)) {
this.#listeners.set(eventName, new Set());
}
this.#listeners.get(eventName).add(handler);
if (this.#es && eventName !== 'message') {
this.#es.addEventListener(eventName, handler);
}
}
close(userInitiated = true) {
this.#closedByUser = userInitiated;
clearInterval(this.#watchdogTimer);
this.#es?.close();
this.#es = null;
}
#dispatch(type, event) {
const handlers = this.#listeners.get(type);
handlers?.forEach((fn) => fn(event));
}
}
// 利用例
const stream = new ReliableEventSource('/api/events/stream');
stream.on('price-update', (e) => {
const { symbol, price } = JSON.parse(e.data);
document.querySelector('#ticker').textContent = `${symbol}: $${price}`;
});
stream.on('connected', (e) => {
console.log('SSE connected', e.lastEventId);
});
stream.connect();
Last-Event-ID は EventSource が 自動的に 次回接続のリクエストヘッダーに付与する。サーバー側で eventsSince(lastEventId) を実装しておけば、欠落なく追いつく 設計になる。
WebSocket — ws ライブラリでの Express 統合
Node.js では ws パッケージが定番。Express アプリと 同一 HTTP サーバー に attach する。
// ws-server.js
const http = require('http');
const express = require('express');
const WebSocket = require('ws');
const { URL } = require('url');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ noServer: true });
const rooms = new Map(); // roomId -> Set<WebSocket>
function joinRoom(ws, roomId) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId).add(ws);
ws.roomId = roomId;
}
function broadcast(roomId, payload, except = null) {
const peers = rooms.get(roomId);
if (!peers) return;
const data = JSON.stringify(payload);
for (const client of peers) {
if (client !== except && client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
function parseCookies(header = '') {
return Object.fromEntries(
header.split(';').map((c) => {
const [k, ...v] = c.trim().split('=');
return [k, decodeURIComponent(v.join('='))];
}).filter(([k]) => k)
);
}
// Upgrade ハンドシェイク(認証はここで)
server.on('upgrade', (req, socket, head) => {
const { pathname, searchParams } = new URL(req.url, 'http://localhost');
if (pathname !== '/ws/chat') {
socket.destroy();
return;
}
const token = searchParams.get('token');
const cookies = parseCookies(req.headers.cookie);
if (!token && !cookies.session) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws, req) => {
const { searchParams } = new URL(req.url, 'http://localhost');
const roomId = searchParams.get('room') || 'lobby';
joinRoom(ws, roomId);
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.send(JSON.stringify({ type: 'welcome', roomId }));
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
ws.send(JSON.stringify({ type: 'error', message: 'invalid json' }));
return;
}
if (msg.type === 'chat') {
broadcast(roomId, {
type: 'chat',
text: msg.text,
at: Date.now(),
});
}
if (msg.type === 'typing') {
broadcast(roomId, { type: 'typing', user: msg.user }, ws);
}
});
ws.on('close', () => {
const peers = rooms.get(ws.roomId);
peers?.delete(ws);
});
});
// サーバー側 heartbeat — 死んだ接続を検出
const heartbeatInterval = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, 30_000);
wss.on('close', () => clearInterval(heartbeatInterval));
app.get('/health', (_req, res) => res.json({ ok: true }));
server.listen(3001, () => {
console.log('WebSocket server ws://localhost:3001/ws/chat');
});
WebSocket クライアント — 指数バックオフ再接続
WebSocket には EventSource のような標準再接続がない。指数バックオフ + jitter を自前で入れる。
// ws-client.js
class ReconnectingWebSocket {
#url;
#ws = null;
#retry = 0;
#maxRetry = 8;
#shouldReconnect = true;
#handlers = { open: [], message: [], close: [] };
constructor(url) {
this.#url = url;
}
connect() {
this.#ws = new WebSocket(this.#url);
this.#ws.onopen = (e) => {
this.#retry = 0;
this.#handlers.open.forEach((fn) => fn(e));
};
this.#ws.onmessage = (e) => {
this.#handlers.message.forEach((fn) => fn(e));
};
this.#ws.onclose = () => {
this.#handlers.close.forEach((fn) => fn());
if (this.#shouldReconnect && this.#retry < this.#maxRetry) {
const delay = Math.min(30_000, 1000 * 2 ** this.#retry);
const jitter = Math.random() * 500;
this.#retry += 1;
setTimeout(() => this.connect(), delay + jitter);
}
};
this.#ws.onerror = () => {
this.#ws.close();
};
}
send(obj) {
if (this.#ws?.readyState === WebSocket.OPEN) {
this.#ws.send(JSON.stringify(obj));
}
}
on(event, fn) {
this.#handlers[event]?.push(fn);
}
close() {
this.#shouldReconnect = false;
this.#ws?.close();
}
}
const chat = new ReconnectingWebSocket('ws://localhost:3001/ws/chat?room=dev');
chat.on('open', () => {
chat.send({ type: 'chat', text: 'hello' });
});
chat.on('message', (e) => {
const msg = JSON.parse(e.data);
console.log('recv', msg);
});
chat.connect();
WebHook 設計 でも触れた 指数バックオフ + jitter は、WebSocket 再接続にも同じ原則が適用される。固定間隔の reconnect 嵐は、サーバー復旧直後に 同時接続ラッシュ を招く。
再接続設計の比較
| 項目 | SSE(EventSource) / WebSocket |
|---|---|
| 標準機能 | ブラウザが自動 reconnect / 自前実装 |
| 再送 ID | Last-Event-ID ヘッダー / 自前 seq・session |
| heartbeat | コメント行 : ping / ping・pong フレーム |
| サイレント切断 | watchdog で close() → 再生成 / ping 失敗で terminate() |
| バックオフ | ブラウザ実装依存 + サーバー retry: / 指数バックオフ + jitter |
| メッセージ欠落 | サーバー event log で replay / クライアント state 同期 API |
Last-Event-ID の実装パターン
- サーバーは 単調増加 ID(整数 or ULID)を各イベントに付与
- イベントは Redis Stream や DB に append-only で保存(TTL 付き)
- 再接続時
Last-Event-ID以降を replay してから live stream に合流 - ログに無い ID(古すぎる)の場合は スナップショット API で現状態を取得してから live へ
チャットの WebSocket では、再接続後に GET /api/chat/rooms/:id/messages?since=seq で穴埋めし、以降 live メッセージを受け取る ハイブリッド が一般的。
HTTP/2 マルチプレクシングと長寿命接続
HTTP/1.1 では同一オリジンへの 並列 TCP 接続数に上限(Chrome で6本)があり、SSE 用の長寿命 GET を1本張ると、接続スロットを消費した。
HTTP/2 のマルチプレクシング により、1 TCP 接続上で複数ストリーム を並列処理できる。つまり:
- SSE 用の 長寿命 GET ストリーム(
/api/events/stream) - 通常の REST API(
/api/users/me) - 静的アセット
を 同じ HTTP/2 接続 で多重化できる。SSE 接続が「接続枠を専有する」問題は HTTP/2 で緩和される。
HTTP/2 上の SSE は 1ストリーム = 1リクエスト として動作する。ストリーム同士は独立しているが、TCP レベルのパケットロス があると全ストリームが一時停止する HoL ブロッキングは残る(HTTP/3/QUIC で改善。HTTP/2 と HTTP/3 の比較 参照)。WebSocket over HTTP/2(RFC 8441 extended CONNECT) は nginx・Cloudflare 等でサポートが進んでいるが、HTTP/1.1 Upgrade より設定が複雑なことがある。まずは HTTP/2 上の 通常 SSE と WS Upgrade が動くかを curl -I --http2 と DevTools の Protocol 列で確認する。
ALB / nginx でのタイムアウト
| レイヤ | 典型デフォルト | 対策 |
|---|---|---|
| AWS ALB idle timeout | 60秒 | 60秒未満の heartbeat |
nginx proxy_read_timeout | 60秒 | 86400s + SSE ping |
| Cloudflare | 100秒(無料) | 15〜30秒 ping |
| ブラウザ Tab sleep | 不定 | Page Visibility API で reconnect |
SSE の : ping\n\n と WebSocket の ping/pong は、プロトコル仕様というより インフラの idle timeout 対策 として必須と考える。
認証・セキュリティ
SSE
- 同一オリジン なら Cookie セッションがそのまま使える(
EventSourceに{ withCredentials: true }) - クロスオリジン は CORS で
Access-Control-Allow-Originを明示。Cookie 使う場合はAllow-Credentials: true - JWT をクエリに載せる方式(
?token=...)はログ・Referer 漏洩リスクがある。短命トークン + 専用スコープ に限定
WebSocket
- Upgrade リクエスト時に Cookie / クエリ token を検証(上記
server.on('upgrade')例) - 接続後の 最初のメッセージで auth する方式(
{ type: 'auth', token })も一般的 - Origin チェック —
wsではverifyClientでoriginヘッダーを検証 - メッセージサイズ上限 — DoS 防止で
maxPayloadを設定
const wss = new WebSocket.Server({
noServer: true,
maxPayload: 64 * 1024, // 64KB
perMessageDeflate: true,
});
セキュリティヘッダー の CSP では connect-src に WebSocket の wss:// を許可する。SSE は通常 'self' の HTTP で足りる。
スケールアウト — 複数 Node プロセスでの fan-out
単一 Node プロセスの Set<res> では、水平スケール時に 別インスタンスの SSE クライアント に届かない。Redis Pub/Sub または Redis Streams で fan-out する。
// sse-redis-fanout.js — 複数 Node プロセス間の SSE fan-out
const express = require('express');
const Redis = require('ioredis');
const app = express();
const pub = new Redis(process.env.REDIS_URL);
const sub = new Redis(process.env.REDIS_URL);
const clients = new Set();
let nextId = 1;
function appendEvent(type, payload) {
return { id: String(nextId++), type, data: payload };
}
function formatSse(event) {
return [
`id: ${event.id}`,
`event: ${event.type}`,
`data: ${JSON.stringify(event.data)}`,
'',
'',
].join('\n');
}
sub.subscribe('sse:broadcast', (err) => {
if (err) throw err;
});
sub.on('message', (_channel, message) => {
const event = JSON.parse(message);
const chunk = formatSse(event);
for (const res of clients) {
res.write(chunk);
}
});
app.get('/api/events/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders?.();
clients.add(res);
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15_000);
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(res);
});
});
async function broadcastViaRedis(type, payload) {
const event = appendEvent(type, payload);
await pub.publish('sse:broadcast', JSON.stringify(event));
}
app.post('/api/events/publish', express.json(), async (req, res) => {
const { type = 'message', payload = {} } = req.body;
await broadcastViaRedis(type, payload);
res.json({ ok: true });
});
app.listen(3002, () => console.log('SSE + Redis fan-out on :3002'));
WebSocket も同様に Redis Pub/Sub → 各 pod の wss.clients へ配信。socket.io を使う場合は Redis adapter がこの役割を担う。
接続数上限・ レート制限 は、SSE/WebSocket ともに DDoS 対策として設定する。1 IP あたり同時接続数、接続レートを nginx またはアプリで制限。
使い分けの決定ツリー
要件から SSE か WebSocket か を5分で切るフロー。
クライアント→サーバーへ高頻度・低遅延送信が必要か? → Yes なら WebSocket
バイナリ(音声・Protobuf)を送るか? → Yes なら WebSocket
サーバー→クライアントの通知だけで、REST POST で操作するか? → Yes なら SSE
ブラウザが EventSource 対応(IE 不要)か? → Yes なら SSE を優先検討
企業プロキシで WebSocket がブロックされる可能性があるか? → 高いなら SSE + REST
チャット・共同編集・ゲームか? → WebSocket(または WebRTC DataChannel)
外部 SaaS イベントを自社 UI に反映するか? → WebHook 受信 + SSE fan-out([WebHook 設計](/blog/webhook-設計-ベストプラクティス/))
ユースケース別の推奨
ケースA — SaaS 管理画面の「新着件数」バッジ
SSE が最適。60秒ポーリングより即時性が高く、WebSocket ほどの運用コストが不要。Stripe WebHook で決済イベントを受け、WebHook 設計 のとおり 202 で即返却 → ワーカー処理 → Redis Pub/Sub → SSE で UI 更新。
ケースB — 社内チャット
WebSocket。typing indicator・既読・メッセージ送信は双方向かつ高頻度。再接続時は REST で since 以降のメッセージを取得してから WS に復帰。
ケースC — CI ログの live tail
SSE。ログ行を data: で流すだけ。クライアントは EventSource で DOM に append。バイナリ不要。
ケースD — モバイルアプリ + プッシュ通知
アプリ foreground 中は WebSocket または SSE(iOS URLSession / Android OkHttp で SSE 解析)。background は FCM / APNs のプッシュが別途必要。SSE/WS だけでは OS がサスペンドすると切れる。
ケースE — 100ms 未満のゲーム同期
WebSocket(場合によって UDP/WebRTC)。SSE の HTTP オーバーヘッドと一方向性では不足。
socket.io を使うべきか
ws は低レベルで軽量。socket.io は部屋(room)、自動 reconnect、フォールバック(long-polling)、Redis adapter がパッケージ化されている。
選ぶ ws | 選ぶ socket.io |
|---|---|
| プロトコルを自分で設計したい | 早くチャット原型を作りたい |
| 非ブラウザクライアントと素の WS で互換 | ブラウザのみでフォールバックが欲しい |
| バンドルサイズ・依存を最小化 | 水平スケールを adapter で楽したい |
どちらも heartbeat と認証 は必要。socket.io は便利だが、カスタムバイナリプロトコル や 非 socket.io クライアント との互換が要らないなら ws の方がシンプル。
2026年時点のブラウザ・インフラ事情
2026年現在、主要ブラウザ(Chrome 136+、Firefox 139+、Safari 18.4+、Edge 136+)は EventSource と WebSocket をデフォルト有効でサポートしている。IE11 はサポート終了済みのため、社内レガシー要件がなければ SSE を第一候補にしてよい。
HTTP/2 / HTTP/3 と長寿命接続
Cloudflare・Fastly・AWS CloudFront など主要 CDN は、2026年時点で HTTP/3(QUIC) をエッジで標準提供している。SSE の長寿命 GET は HTTP/2 または HTTP/3 上の 1ストリーム として動作し、同一接続で REST API と多重化できる(詳細は HTTP/2 と HTTP/3 の比較)。HTTP/3 では TCP HoL ブロッキングが解消されるため、SSE ストリームと並列 API が同時に遅延する 事象は HTTP/2 より減る。
WebSocket over HTTP/2(RFC 8441 extended CONNECT)は nginx 1.25+ と Cloudflare で本番利用可能だが、設定の複雑さから HTTP/1.1 Upgrade がまだ現場では多い。新規構築では LB の WebSocket サポート表を先に確認する。
Fetch API ストリームとの比較
fetch() + ReadableStream で SSE 相当のパースを自前実装する手法もある。利点は カスタムヘッダー(Authorization: Bearer)を付けられること。欠点は 自動再接続と Last-Event-ID をすべて自前で書く必要があることだ。
| 方式 | 認証ヘッダー | 自動再接続 | 実装コスト |
|---|---|---|---|
EventSource | Cookie のみ(同一オリジン) | 標準 | 低 |
fetch + stream | Bearer 等フル対応 | 自前 | 中〜高 |
| WebSocket | Upgrade 時 or 初回 msg | 自前 | 中 |
Bearer トークンが必須で Cookie が使えない SPA では、(1) 短命トークンをクエリに載せた SSE、(2) fetch ストリーム、(3) WebSocket 初回 auth の3択になる。通知だけなら (1)、双方向なら (3) が定番。
Node.js 22 LTS と Express 5
2026年の本番 Node は 22 LTS が主流。res.flushHeaders() は Express 5(2024年 GA)で res.flushHeaders?.() として型安全に呼べる。ws 4.x は Node 22 の WebSocket グローバル(実験的 undici 連携)とは別物なので、サーバー側は引き続き ws パッケージを使う。
// Express 5 + Node 22 — SSE エンドポイントの最小構成
import express from 'express';
const app = express();
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.flushHeaders();
const timer = setInterval(() => {
res.write(`data: ${JSON.stringify({ ts: Date.now() })}\n\n`);
}, 2000);
req.on('close', () => clearInterval(timer));
});
app.listen(3000);
監視と SLO(2026年の定番スタック)
本番では次のメトリクスを Prometheus + Grafana で可視化するのが 2026 年の標準的な運用だ。
sse_active_connections— インスタンスごとの同時接続数sse_events_sent_total— イベント種別ラベル付きカウンタws_active_connections/ws_messages_in_total/ws_messages_out_totalrealtime_reconnect_total— クライアント側は RUM(Sentry / Datadog)で集計
SLO 例: p99 イベント遅延 2秒以内(SSE)、p99 メッセージ往復 100ms 以内(WebSocket チャット)。heartbeat 間隔は SLO と LB タイムアウトの中間値で決める。
よくある落とし穴
SSE エンドポイントで res.json() を呼ぶとストリームが終了する。エラー時も 同一接続内 で event: error を送るか、HTTP ステータスで接続前に弾く。接続後のエラーは data: {"error":"..."} + 必要なら close。
- バッファリング — nginx / Express の compression ミドルウェアが SSE を溜める。
X-Accel-Buffering: noとproxy_buffering off - 圧縮 —
text/event-streamへの gzip は遅延の元。SSE では通常無効化する - CORS preflight — EventSource は シンプル GET なので preflight 不要。カスタムヘッダーを付けると preflight が発生し SSE 不能に(CORS preflight 参照)
- 接続数リーク —
req.on('close')忘れでメモリリーク - WebSocket の half-open — ping/pong なしで LB だけ切断し、サーバーがゾンビ接続を保持
ローカル検証手順
# SSE を curl で確認(-N でバッファ無効)
curl -N -H "Accept: text/event-stream" http://localhost:3000/api/events/stream
# イベント注入
curl -X POST http://localhost:3000/api/events/publish \
-H "Content-Type: application/json" \
-d '{"type":"alert","payload":{"msg":"deploy done"}}'
# WebSocket(wscat)
npx wscat -c "ws://localhost:3001/ws/chat?room=dev&token=demo"
ブラウザ DevTools → Network → stream リクエスト → EventStream タブで SSE を目視確認。WebSocket は Messages タブ。
実装チェックリスト
一方向か双方向かを決定ツリーで確定する
SSE: text/event-stream・heartbeat・Last-Event-ID replay を実装する
WebSocket: Upgrade 認証・ping/pong・maxPayload・指数バックオフ reconnect
ALB/nginx/Cloudflare の idle timeout より短い heartbeat 間隔を設定
HTTP/2 有効時は multiplex で SSE + API が共存するか確認
Redis Pub/Sub で水平スケール時の fan-out を設計
WebHook 経路と SSE 経路の役割分担を文書化([WebHook 設計](/blog/webhook-設計-ベストプラクティス/))
接続数・メッセージレートの上限と監視(Prometheus metrics)を追加
関連記事
あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| OpenTelemetry Node.js 実装ガイド | OpenTelemetry Node.js 実装ガイド|分散トレーシング設定とJaeger/Tempo連携 |
| CSS Grid vs Flexbox | CSS Grid vs Flexbox|どっちを使う?使い分けと実戦レイアウト |
| JWKSとJWT鍵ローテーション運用ガイド | JWKSとJWT鍵ローテーション運用ガイド|kid検証・ゼロダウンタイム・Node.js実装 |
| Kubernetes Liveness・Readiness・Startup プローブの違いと設計 | Kubernetes Liveness・Readiness・Startup プローブの違いと設計|再起動ループを防ぐ実践ガイド |
| セキュアなCookie設定ガイド | セキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策 |
| APIデバッグで使うツールの使い分け | APIデバッグで使うツールの使い分け|JSON整形・JWT・Base64のワークフロー |
| プログラミングの命名規則まとめ | プログラミングの命名規則まとめ|camelCase, snake_case, PascalCaseの使い分け |
| Double Submit Cookie完全実装 | Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃 |
| CSSの単位はpxとremどちらが良い? | CSSの単位はpxとremどちらが良い?|基準16pxの理由と使い分け |
| HTTPSレコードとSVCBレコードの完全解説 | HTTPSレコードとSVCBレコードの完全解説|ALPN・ECH・HTTP/3のDNS設定 |
| HTTPステータスコード一覧 | HTTPステータスコード一覧|200・404・500の意味と対処法 |
| JSONとCSVの相互変換 | JSONとCSVの相互変換|どちらを選ぶべきか?用途と違い |
まとめ
SSE WebSocket 違い の核心は 方向性 と プロトコルの複雑さ だ。通知・ログ・ダッシュボード更新のような サーバー push 一方向 なら、Express + EventSource 実装 の SSE が最小コストで強い。チャット・共同編集 のような 双方向低遅延 なら ws ベースの WebSocket を選ぶ。
どちらも heartbeat と 再接続、Last-Event-ID または since シーケンス による欠落回復が本番の生命線。HTTP/2 マルチプレクシング下では SSE 長寿命接続と REST が同一 TCP で共存しやすくなるが、LB タイムアウトは別問題として残る。
外部イベントの取り込みは WebHook設計のベストプラクティス の署名・べき等性・非同期処理と組み合わせ、WebHook → 内部キュー → SSE/WebSocket fan-out の3層にすると、SaaS 連携とリアルタイム UI をきれいに分離できる。新規機能ではまず決定ツリーで方向性を確定し、過剰に WebSocket を選ばない——多くの「リアルタイム更新」は SSE で足りる。