WebSocketとSSE - リアルタイム通信の仕組みと使い分け

通常のHTTPリクエストはクライアントからの要求に対してサーバーが応答する一方向のモデル。リアルタイム通信を実現する手段として WebSocketSSE(Server-Sent Events) がある。

HTTP ポーリングとの比較

リアルタイム通信の実現方法は複数ある。

方式仕組み課題
短時間ポーリングクライアントが定期的にリクエストを送る無駄なリクエストが多い・遅延がある
ロングポーリングレスポンスを保留してデータが来たら返す接続が増えるとサーバー負荷が高い
SSEサーバーからクライアントへの一方向ストリーミングクライアントからの送信はHTTPで別途行う
WebSocket双方向のリアルタイム通信HTTP/2との相性・プロキシ設定が必要な場合がある

SSE(Server-Sent Events)

概要

HTTPの仕組みを利用して、サーバーからクライアントへデータをプッシュし続ける技術。接続を維持したまま、サーバーが任意のタイミングでイベントを送信できる。

接続フロー

sequenceDiagram
    participant C as クライアント
    participant S as サーバー
    C->>S: GET /events<br/>Accept: text/event-stream
    S-->>C: 200 OK<br/>Content-Type: text/event-stream
    Note over S,C: 接続を維持
    S-->>C: data: {"message": "更新1"}
    S-->>C: data: {"message": "更新2"}
    S-->>C: data: {"message": "更新3"}
    Note over C: 接続切断時は自動再接続

データフォーマット

SSEは text/event-stream という特定のテキスト形式で送信される。

data: シンプルなメッセージ\n\n

event: update
data: {"id": 1, "message": "更新データ"}\n\n

id: 42
event: notification
data: {"type": "alert", "text": "新しいメッセージ"}\n\n

: これはコメント(クライアントには届かない)\n\n

retry: 3000\n\n
フィールド説明
data送信するデータ。複数行にわたる場合は data: を繰り返す
eventイベント名。省略時は message
idイベントのID。再接続時に Last-Event-ID ヘッダーで送られる
retry再接続までの待機時間(ミリ秒)
: コメント行(ハートビート用途にも使われる)

クライアント実装(JavaScript)

const evtSource = new EventSource('/events')

// デフォルトイベント(event フィールドなし)
evtSource.onmessage = (event) => {
  console.log(event.data)
}

// カスタムイベント
evtSource.addEventListener('update', (event) => {
  const data = JSON.parse(event.data)
  console.log(data)
})

// エラー処理
evtSource.onerror = (err) => {
  console.error('SSE error:', err)
}

// 切断
evtSource.close()

SSEは接続が切れると自動で再接続する(retry フィールドで間隔を制御)。

サーバー実装(Node.js / Express)

app.get('/events', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')

  // クライアントへのイベント送信
  const sendEvent = (data, event = 'message') => {
    res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
  }

  // ハートビート(接続維持)
  const heartbeat = setInterval(() => {
    res.write(': ping\n\n')
  }, 30000)

  // 接続切断時のクリーンアップ
  req.on('close', () => {
    clearInterval(heartbeat)
  })
})

SSEの制約

  • 一方向のみ: サーバー → クライアントの送信だけ。クライアントからのメッセージはHTTPリクエストで別途送る
  • テキストのみ: バイナリデータは Base64 エンコードが必要
  • ブラウザの同時接続数制限: HTTP/1.1では同一ドメインへの接続数制限(通常6)があり、SSEで消費される。HTTP/2を使えば解消される

WebSocket

概要

HTTP接続をアップグレードして確立する、双方向のリアルタイム通信プロトコル。一度接続が確立されると、サーバーとクライアントのどちらからもメッセージを送り合える。

接続フロー

sequenceDiagram
    participant C as クライアント
    participant S as サーバー
    C->>S: HTTP GET /chat<br/>Upgrade: websocket<br/>Connection: Upgrade<br/>Sec-WebSocket-Key: xxx
    S-->>C: 101 Switching Protocols<br/>Upgrade: websocket<br/>Sec-WebSocket-Accept: yyy
    Note over C,S: WebSocket接続確立(双方向)
    C->>S: メッセージ送信
    S->>C: メッセージ送信
    C->>S: メッセージ送信
    S->>C: Ping
    C-->>S: Pong
    C->>S: Close フレーム
    S-->>C: Close フレーム

ハンドシェイク

WebSocket接続はHTTPのアップグレードリクエストで始まる。

リクエスト:

GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

レスポンス:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Sec-WebSocket-Accept の値は Sec-WebSocket-Key と固定のGUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 を結合してSHA-1ハッシュを取り、Base64エンコードしたもの。

クライアント実装(JavaScript)

const ws = new WebSocket('wss://example.com/chat')

// 接続確立
ws.onopen = () => {
  console.log('接続完了')
  ws.send(JSON.stringify({ type: 'join', room: 'general' }))
}

// メッセージ受信
ws.onmessage = (event) => {
  const data = JSON.parse(event.data)
  console.log(data)
}

// エラー処理
ws.onerror = (err) => {
  console.error('WebSocket error:', err)
}

// 接続終了
ws.onclose = (event) => {
  console.log(`切断: code=${event.code}, reason=${event.reason}`)
}

// メッセージ送信
ws.send('テキストメッセージ')
ws.send(JSON.stringify({ type: 'message', text: 'こんにちは' }))

// 切断
ws.close(1000, '正常終了')

接続状態

ws.readyState
// 0: CONNECTING  接続中
// 1: OPEN        接続確立済み
// 2: CLOSING     切断処理中
// 3: CLOSED      切断済み

サーバー実装(Node.js / ws)

const { WebSocketServer } = require('ws')

const wss = new WebSocketServer({ port: 8080 })

wss.on('connection', (ws, req) => {
  console.log('クライアント接続')

  // メッセージ受信
  ws.on('message', (data) => {
    const message = JSON.parse(data.toString())

    // 全クライアントにブロードキャスト
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message))
      }
    })
  })

  // 切断
  ws.on('close', () => {
    console.log('クライアント切断')
  })

  // ハートビート(Ping/Pong)
  ws.isAlive = true
  ws.on('pong', () => { ws.isAlive = true })
})

// 定期的にゾンビ接続をクリーンアップ
const heartbeat = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) { ws.terminate(); return }
    ws.isAlive = false
    ws.ping()
  })
}, 30000)

クローズコード

コード意味
1000正常終了
1001エンドポイント離脱(ページ遷移など)
1002プロトコルエラー
1003受け入れられないデータ型
1006異常切断(CloseフレームなしでTCPが切れた)
1007不正なデータ
1011サーバー内部エラー

WebSocket vs SSE 比較

項目WebSocketSSE
通信方向双方向サーバー → クライアントのみ
プロトコルws:// / wss://HTTP
データ形式テキスト・バイナリテキストのみ
自動再接続実装が必要ブラウザが自動で行う
接続確立HTTPアップグレード通常のHTTPリクエスト
HTTP/2との相性別プロトコルのため無関係HTTP/2ストリームを活用できる
プロキシ・CDN対応が必要な場合があるHTTPなので基本的にそのまま動く
ブラウザサポート全モダンブラウザ対応全モダンブラウザ対応(IEは除く)
実装の複雑さやや複雑シンプル

使い分けのガイド

flowchart TD
  A[リアルタイム通信の選定] --> B{通信方向}
  B --> C["サーバー → クライアント<br/>のみ"]
  B --> D["双方向が必要"]

  C --> E{更新頻度}
  E --> E1["低〜中<br/>(通知・フィード)"] --> R1[SSE]
  E --> E2[高頻度・バイナリも必要] --> R2[WebSocket]

  D --> F{用途}
  F --> F1["チャット<br/>コラボレーション"] --> R3[WebSocket]
  F --> F2["ゲーム<br/>リアルタイム操作"] --> R4[WebSocket]
  F --> F3[IoTデバイス制御] --> R5[WebSocket]

SSEが向いているケース

  • ニュースフィード・SNSのタイムライン更新
  • 株価・スポーツスコアなどのデータ配信
  • 進捗バー・バックグラウンドジョブの状態通知
  • ダッシュボードのメトリクス更新

WebSocketが向いているケース

  • チャットアプリ・コメント機能
  • 共同編集(Google Docs のようなリアルタイム協調)
  • オンラインゲーム
  • ライブ配信のコメント・リアクション
  • IoTデバイスとの双方向制御

参考リンク