SSE vs WebSocket 使い分け|EventSource 実装と再接続設計

SSE WebSocket EventSource Node.js Express リアルタイム API設計
結論
  • サーバー→クライアントの一方向通知(ダッシュボード、ログ tail、株価、進捗バー)なら SSE + EventSource
  • 双方向・低遅延(チャット、共同編集、ゲーム操作)なら WebSocket(ws 等)
  • SSE は通常 HTTP のまま実装でき、自動再接続と Last-Event-ID が標準。
  • WebSocket はバイナリ・高頻度双方向に強いが、LB タイムアウトと自前 heartbeat が必須。
  • 外部 SaaS からのイベントは WebHook が別レイヤー。

SSE WebSocket 違い — まず押さえる3つの軸

「リアルタイム通信」と一口に言っても、要件は大きく3つに分かれる。

  1. 通信方向 — サーバー→クライアントだけか、双方向か
  2. プロトコル — 通常 HTTP の延長か、WebSocket ハンドシェイク後の独立フレームか
  3. 運用 — プロキシ・LB・再接続・認証の扱い

Server-Sent Events(SSE) は HTML5 標準の サーバー→クライアント一方向 プッシュ。Content-Type: text/event-stream の HTTP レスポンスを開きっぱなしにし、data: 行でイベントを流す。WebSocket は TCP 接続を Upgrade した後、軽量フレームで 双方向 メッセージをやり取りする。

観点SSEWebSocket
方向サーバー → クライアント(一方向)双方向
プロトコル通常 HTTP(GET)HTTP Upgrade → ws: / wss:
ブラウザ APIEventSource(組み込み)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() / wssocket.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つ。

  1. Content-Type: text/event-streamCache-Control: no-cache
  2. 接続維持 — nginx 等の proxy_read_timeout を超えないよう コメント行(: ping\n\n で heartbeat
  3. Last-Event-ID — 再接続時に欠落イベントを再送
  4. 接続終了時のクリーンアップ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 / pingpong フレーム
サイレント切断 watchdog で close() → 再生成 / ping 失敗で terminate()
バックオフ ブラウザ実装依存 + サーバー retry: / 指数バックオフ + jitter
メッセージ欠落 サーバー event log で replay / クライアント state 同期 API

Last-Event-ID の実装パターン

  1. サーバーは 単調増加 ID(整数 or ULID)を各イベントに付与
  2. イベントは Redis Stream や DB に append-only で保存(TTL 付き)
  3. 再接続時 Last-Event-ID 以降を replay してから live stream に合流
  4. ログに無い 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 / WebSocket の注意点

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 上の 通常 SSEWS Upgrade が動くかを curl -I --http2 と DevTools の Protocol 列で確認する。

ALB / nginx でのタイムアウト

レイヤ典型デフォルト対策
AWS ALB idle timeout60秒60秒未満の heartbeat
nginx proxy_read_timeout60秒86400s + SSE ping
Cloudflare100秒(無料)15〜30秒 ping
ブラウザ Tab sleep不定Page Visibility API で reconnect

SSE の : ping\n\nWebSocket の 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 では verifyClientorigin ヘッダーを検証
  • メッセージサイズ上限 — 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分で切るフロー。

1

クライアント→サーバーへ高頻度・低遅延送信が必要か? → Yes なら WebSocket

2

バイナリ(音声・Protobuf)を送るか? → Yes なら WebSocket

3

サーバー→クライアントの通知だけで、REST POST で操作するか? → Yes なら SSE

4

ブラウザが EventSource 対応(IE 不要)か? → Yes なら SSE を優先検討

5

企業プロキシで WebSocket がブロックされる可能性があるか? → 高いなら SSE + REST

6

チャット・共同編集・ゲームか? → WebSocket(または WebRTC DataChannel)

7

外部 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+)は EventSourceWebSocket をデフォルト有効でサポートしている。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 をすべて自前で書く必要があることだ。

方式認証ヘッダー自動再接続実装コスト
EventSourceCookie のみ(同一オリジン)標準
fetch + streamBearer 等フル対応自前中〜高
WebSocketUpgrade 時 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_total
  • realtime_reconnect_total — クライアント側は RUM(Sentry / Datadog)で集計

SLO 例: p99 イベント遅延 2秒以内(SSE)、p99 メッセージ往復 100ms 以内(WebSocket チャット)。heartbeat 間隔は SLO と LB タイムアウトの中間値で決める。

よくある落とし穴

SSE を JSON レスポンスと混同しない

SSE エンドポイントで res.json() を呼ぶとストリームが終了する。エラー時も 同一接続内event: error を送るか、HTTP ステータスで接続前に弾く。接続後のエラーは data: {"error":"..."} + 必要なら close

  1. バッファリング — nginx / Express の compression ミドルウェアが SSE を溜める。X-Accel-Buffering: noproxy_buffering off
  2. 圧縮text/event-stream への gzip は遅延の元。SSE では通常無効化する
  3. CORS preflight — EventSource は シンプル GET なので preflight 不要。カスタムヘッダーを付けると preflight が発生し SSE 不能に(CORS preflight 参照)
  4. 接続数リークreq.on('close') 忘れでメモリリーク
  5. 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 タブ。

実装チェックリスト

1

一方向か双方向かを決定ツリーで確定する

2

SSE: text/event-stream・heartbeat・Last-Event-ID replay を実装する

3

WebSocket: Upgrade 認証・ping/pong・maxPayload・指数バックオフ reconnect

4

ALB/nginx/Cloudflare の idle timeout より短い heartbeat 間隔を設定

5

HTTP/2 有効時は multiplex で SSE + API が共存するか確認

6

Redis Pub/Sub で水平スケール時の fan-out を設計

7

WebHook 経路と SSE 経路の役割分担を文書化([WebHook 設計](/blog/webhook-設計-ベストプラクティス/))

8

接続数・メッセージレートの上限と監視(Prometheus metrics)を追加

関連記事

あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
OpenTelemetry Node.js 実装ガイドOpenTelemetry Node.js 実装ガイド|分散トレーシング設定とJaeger/Tempo連携
CSS Grid vs FlexboxCSS 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 で足りる。