plan-4ging

2026/04/04

WebSocket

クライアントとサーバー間で持続的な双方向通信を実現するプロトコル
一度接続を確立すると、どちらからでも任意のタイミングでデータを送受信できる

HTTP との違い

HTTP

  • 必ずクライアントが先にリクエストを送る
  • 1リクエスト=1レスポンスで接続が終わる
  • サーバーから能動的にデータを送れない
  • 毎回ヘッダーを送るオーバーヘッドがある

WebSocket

  • クライアント ⇄ サーバー、双方向にいつでも送受信可能
  • 最初の HTTP ハンドシェイク後に接続を維持する
  • サーバーから能動的にデータを送れる(サーバープッシュ)
  • ヘッダーのオーバーヘッドがほぼない(フレーム形式)
  • 接続を切るまでデータのやり取りが続く

接続確立(ハンドシェイク)

Upgradeヘッダーフィールドを指定して、既に確立された接続を websocket にアップグレードする

① クライアントが HTTP で接続要求を送る
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket                ← WebSocket へのアップグレード要求
Connection: Upgrade
Sec-WebSocket-Key: xxx
Sec-WebSocket-Version: 13

② サーバーが接続を承認する
HTTP/1.1 101 Switching Protocols  ← プロトコル切替
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: xxx

③ 以降は WebSocket フレームで双方向通信
ws://example.com/ws  または  wss://example.com/ws(TLS)

永続的な接続方法(Long Polling, SSE, WebSocket)

Long Polling

Pros

  • HTTP のみで実現できる、プロキシ・ファイアウォールを通過しやすい

Cons

  • クライアント側から定期的にリクエストを行う必要がある
  • 接続の確立・切断を繰り返すオーバーヘッドが大きい
  • サーバー負荷が高い、リアルタイム性が若干劣る
クライアント → サーバー:リクエスト
 ↓ 定期的にリクエスト
クライアント ← サーバー:レスポンス

SSE(Server-Sent Events)

Pros

  • HTTP/1.1 で動く、自動再接続が組み込まれている
  • 実装がシンプル、プロキシを通過しやすい

Cons

  • サーバー → クライアントの一方向のみ
  • クライアントからのデータ送信は別途 HTTP が必要
クライアント → サーバー:接続確立(HTTP GET)
クライアント ← サーバー:一方向にストリームで送り続ける

比較

Long PollingSSEWebSocket
通信方向擬似双方向サーバー→クライアント完全双方向
プロトコルHTTPHTTPWS
自動再接続手動実装ブラウザ組み込み手動実装
サーバー負荷
プロキシ通過△(設定が必要な場合あり)
実装コスト
向いている用途簡易通知通知・ライブフィードチャット・ゲーム・共同編集

基本実装

クライアント側(ブラウザ)

class WebSocketClient {
  constructor(url) {
    this.url = url
    this.ws = null
    this.listeners = {}
  }

  connect() {
    this.ws = new WebSocket(this.url)

    // 接続確立
    this.ws.addEventListener('open', (event) => {
      console.log('接続確立')
      this.emit('open', event)
    })

    // メッセージ受信
    this.ws.addEventListener('message', (event) => {
      try {
        const data = JSON.parse(event.data)
        console.log('受信:', data)
        this.emit('message', data)
      } catch {
        console.error('JSON パースエラー:', event.data)
      }
    })

    // 接続切断
    this.ws.addEventListener('close', (event) => {
      console.log(`切断 code=${event.code} reason=${event.reason}`)
      this.emit('close', event)
    })

    // エラー
    this.ws.addEventListener('error', (event) => {
      console.error('WebSocket エラー:', event)
      this.emit('error', event)
    })
  }

  // メッセージ送信
  send(type, payload) {
    if (this.ws?.readyState !== WebSocket.OPEN) {
      console.warn('WebSocket が未接続です')
      return
    }
    this.ws.send(JSON.stringify({ type, payload }))
  }

  // 接続を閉じる
  disconnect(code = 1000, reason = 'Normal closure') {
    this.ws?.close(code, reason)
  }

  // イベントリスナー登録
  on(event, callback) {
    if (!this.listeners[event]) this.listeners[event] = []
    this.listeners[event].push(callback)
    return () => this.off(event, callback)
  }

  off(event, callback) {
    this.listeners[event] = this.listeners[event]?.filter(cb => cb !== callback)
  }

  emit(event, data) {
    this.listeners[event]?.forEach(cb => cb(data))
  }
}

// 使用例
const client = new WebSocketClient('wss://api.example.com/ws')

client.on('open', () => {
  // 接続確立後に送信する
  client.send('chat_message', { text: 'こんにちは!', roomId: 'room-1' })
})

const unsubscribe = client.on('message', (data) => {
  if (data.type === 'chat_message') {
    displayMessage(data.payload)
  }
})

client.connect()

// クリーンアップ
unsubscribe()
client.disconnect()

サーバー側(Node.js / ws ライブラリ)

const http = require('http')
const WebSocket = require('ws')

const server = http.createServer()
const wss = new WebSocket.Server({ server })

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

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

      // 接続中の全クライアントに送信
      wss.clients.forEach((client) => {
        if (client.readyState === WebSocket.OPEN) {
          client.send(JSON.stringify(data))
        }
      })
    } catch (err) {
      ws.send(JSON.stringify({
        type: 'error',
        payload: { message: 'Invalid JSON' },
      }))
    }
  })

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

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

server.listen(8080, () => {
  console.log('WebSocket サーバー起動 :8080')
})

認証/認可

WebSocket は HTTP ヘッダーを通常の方法で扱えないため、認証の実装方法が HTTP と異なる

接続 URL にトークンを付与する

// 接続 URL にトークンを付与する
const token = localStorage.getItem('access_token')
const ws    = new WebSocket(`wss://api.example.com/ws?token=${token}`)

// サーバー:接続時に URL パラメータからトークンを検証する
const url    = require('url')
const jwt    = require('jsonwebtoken')

wss.on('connection', async (ws, req) => {
  const params = new url.URLSearchParams(req.url.replace('/?', ''))
  const token  = params.get('token')

  if (!token) {
    ws.close(4001, 'Unauthorized')  // 4000-4999 はアプリ定義のコード
    return
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET)
    ws.userId = payload.sub
    ws.role   = payload.role
  } catch {
    ws.close(4001, 'Invalid token')
    return
  }

  // 認証成功後の処理
  console.log(`認証済み接続 userId=${ws.userId}`)
})

接続後の初回メッセージで認証する

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

ws.addEventListener('open', () => {
  // 接続直後に認証メッセージを送る
  ws.send(JSON.stringify({
    type:    'authenticate',
    payload: { token: localStorage.getItem('access_token') },
  }))
})

ws.addEventListener('message', (event) => {
  const data = JSON.parse(event.data)
  if (data.type === 'authenticated') {
    console.log('認証成功')
    // 以降のメッセージ処理を開始する
  } else if (data.type === 'auth_error') {
    console.error('認証失敗:', data.payload.message)
    ws.close()
  }
})

// サーバー:認証メッセージを受け取るまでの間は他の処理を受け付けない
wss.on('connection', (ws) => {
  ws.isAuthenticated = false

  // 認証タイムアウト(10秒以内に認証しないと切断)
  const authTimeout = setTimeout(() => {
    if (!ws.isAuthenticated) {
      ws.close(4001, 'Authentication timeout')
    }
  }, 10000)

  ws.on('message', (rawData) => {
    const { type, payload } = JSON.parse(rawData.toString())

    // 未認証の場合は authenticate メッセージのみ受け付ける
    if (!ws.isAuthenticated) {
      if (type !== 'authenticate') {
        ws.send(JSON.stringify({ type: 'error', payload: { message: 'Not authenticated' } }))
        return
      }

      try {
        const decoded = jwt.verify(payload.token, process.env.JWT_SECRET)
        ws.userId        = decoded.sub
        ws.isAuthenticated = true
        clearTimeout(authTimeout)
        ws.send(JSON.stringify({ type: 'authenticated', payload: { userId: ws.userId } }))
      } catch {
        ws.close(4001, 'Invalid token')
      }
      return
    }

    // 認証済みの場合は通常のメッセージ処理
    handleMessage(ws, type, payload)
  })
})

スケールアウト/負荷分散(Redis Pub/Sub)

複数サーバーでの問題点

スケールアウト時など、異なるサーバー間でのデータ共有ができない

サーバーA:user1・user2 が接続中
サーバーB:user3・user4 が接続中

user1 がメッセージを送信した場合、
サーバーA はサーバーB に接続している user3・user4 に送れない

解決策(Redis Pub/Sub)

Redis Pub/Sub でサーバー間のデータを中継する

const Redis    = require('ioredis')
const publisher  = new Redis(process.env.REDIS_URL)
const subscriber = new Redis(process.env.REDIS_URL)

// チャンネルを購読する
subscriber.subscribe('chat:room-1', 'chat:room-2')

// Redis からメッセージを受け取ったら接続中のクライアントに送る
subscriber.on('message', (channel, message) => {
  const roomId = channel.replace('chat:', '')
  const room   = rooms.get(roomId)

  if (!room) return

  // このサーバーに接続しているクライアントにブロードキャスト
  room.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message)
    }
  })
})

// メッセージを受け取ったら Redis に Publish する
function broadcastToRoom(roomId, message) {
  const channel = `chat:${roomId}`
  const data    = JSON.stringify(message)

  // Redis に Publish → 全サーバーが受け取ってブロードキャスト
  publisher.publish(channel, data)
}
                  ┌─────────────────┐
                  │  Redis Pub/Sub  │
                  └────────┬────────┘
                 subscribe │ publish
        ┌──────────────────┼──────────────────┐
        ↓                  ↓                  ↓
┌──────────────┐   ┌──────────────┐  ┌──────────────┐
│   server A   │   │   server B   │  │   server C   │
│  user1 user2 │   │ user3 user4  │  │  user5 user6 │
└──────────────┘   └──────────────┘  └──────────────┘

再接続・ヘルスチェック

Ping/Pong

WebSocket の接続が死活不明(ゾンビ接続)のままになることがある
定期的に Ping を送って応答がなければ切断する

const PING_INTERVAL = 30000  // 30秒ごとに Ping

const pingTimer = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) {
      console.log(`ゾンビ接続を切断 userId=${ws.userId}`)
      ws.terminate()  // close() ではなく terminate() で強制終了
      return
    }
    ws.isAlive = false  // 次の Pong が来るまで false にする
    ws.ping()           // Ping を送る
  })
}, PING_INTERVAL)

wss.on('connection', (ws) => {
  ws.isAlive = true
  ws.on('pong', () => { ws.isAlive = true })  // Pong を受信したら生存確認
})

// サーバー終了時にタイマーをクリアする
wss.on('close', () => clearInterval(pingTimer))

自動再接続(クライアント側)

class ReconnectingWebSocket {
  constructor(url) {
    this.url = url
    this.ws = null
    this.shouldReconnect = true
    this.retryDelay = 3000 // 3秒後に再接続

    this.connect()
  }

  connect() {
    console.log('接続中...')
    this.ws = new WebSocket(this.url)

    this.ws.addEventListener('open', () => {
      console.log('接続確立')
    })

    this.ws.addEventListener('message', (event) => {
      const data = JSON.parse(event.data)
      console.log('受信:', data)
    })

    this.ws.addEventListener('close', () => {
      console.log('切断されました')

      if (this.shouldReconnect) {
        console.log(`${this.retryDelay}ms 後に再接続します`)
        setTimeout(() => this.connect(), this.retryDelay)
      }
    })

    this.ws.addEventListener('error', (event) => {
      console.error('WebSocket エラー:', event)
    })
  }

  send(type, payload) {
    if (this.ws?.readyState !== WebSocket.OPEN) {
      console.warn('WebSocket が未接続です')
      return
    }

    this.ws.send(JSON.stringify({ type, payload }))
  }

  disconnect() {
    this.shouldReconnect = false
    this.ws?.close()
  }
}

// 使用例
const ws = new ReconnectingWebSocket('wss://api.example.com/xxx')

// 接続後に送信する
ws.ws.addEventListener('open', () => {
  ws.send('chat_message', { text: 'こんにちは!' })
})

ユースケース

向いている場面

  • リアルタイムチャット
    • メッセージを送受信・既読管理・入力中表示
  • オンラインゲーム
    • プレイヤーの位置・アクションをリアルタイム同期
  • 共同編集
    • 他ユーザーの編集内容をリアルタイムで反映する
  • 金融・株価のリアルタイム更新
    • 価格変動をサーバーから即座にプッシュする
  • ライブ通知
    • 「いいねしました」などのリアルタイム通知
  • IoT・センサーデータのストリーミング
    • 機器からのデータを継続的に受信する

向いていない場面(SSE の方が向いている場面)

  • サーバー → クライアントの一方向通知のみ
  • AI のストリーミングレスポンス表示
  • ログ・進捗のリアルタイム表示

注意点

メッセージプロトコル設計

最初にメッセージの型と構造を定義しておく
type で処理を分岐する設計にすると保守性が高い

const MessageTypes = {
  // 認証
  AUTHENTICATE:    'authenticate',
  AUTHENTICATED:   'authenticated',
  AUTH_ERROR:      'auth_error',

  // チャット
  CHAT_MESSAGE:    'chat_message',
  JOIN_ROOM:       'join_room',
  LEAVE_ROOM:      'leave_room',
  USER_JOINED:     'user_joined',
  USER_LEFT:       'user_left',

  // システム
  PING:            'ping',
  PONG:            'pong',
  ERROR:           'error',
}

// 統一されたメッセージ形式
// { type, payload, timestamp, messageId }
// messageId を付けることで重複処理・冪等性の確保ができる

メモリリーク・接続数管理

切断時にクリーンアップする
定期的に接続数をモニタリングする

// NG:接続が増えるとメモリリークが起きるパターン
const allConnections = []
wss.on('connection', (ws) => {
  allConnections.push(ws)
  // 切断時に配列から削除していない → 接続数が増えると配列が肥大化する
})

// OK:切断時にクリーンアップする
const connections = new Set()
wss.on('connection', (ws) => {
  connections.add(ws)
  ws.on('close', () => {
    connections.delete(ws)  // 切断時に削除する
    cleanupUserData(ws)     // ユーザーに紐づくデータも削除する
  })
})

// 定期的に接続数をモニタリングする
setInterval(() => {
  console.log(`接続数: ${wss.clients.size}`)
  // Prometheus・Datadog などに送ることも検討する
}, 60000)

レート制限

悪意あるクライアントが大量メッセージを送ることを防ぐ

wss.on('connection', (ws) => {
  ws.messageCount   = 0
  ws.rateLimitTimer = setInterval(() => {
    ws.messageCount = 0  // 1秒ごとにカウントをリセット
  }, 1000)

  ws.on('message', (data) => {
    ws.messageCount++

    // 1秒間に 20メッセージを超えたらレート制限
    if (ws.messageCount > 20) {
      ws.send(JSON.stringify({
        type:    'error',
        payload: { message: 'Rate limit exceeded' }
      }))
      return
    }

    handleMessage(ws, data)
  })

  ws.on('close', () => {
    clearInterval(ws.rateLimitTimer)  // タイマーを確実にクリアする
  })
})

Proxy・LB(Load Balancer)設定

  • UpgradeConnection: Upgrade を正しく通す
  • 通常の HTTP タイムアウト設定のままだと、アイドル状態の WebSocket 接続が途中で切られることがある
    • タイムアウト設定(read timeout や idle timeout)は長めに設定

参考