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)
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-WebSocket-Key
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-WebSocket-Version
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-WebSocket-Accept
- https://developer.mozilla.org/ja/docs/Web/HTTP/Guides/Protocol_upgrade_mechanism
永続的な接続方法(Long Polling, SSE, WebSocket)
Long Polling
Pros
- HTTP のみで実現できる、プロキシ・ファイアウォールを通過しやすい
Cons
- クライアント側から定期的にリクエストを行う必要がある
- 接続の確立・切断を繰り返すオーバーヘッドが大きい
- サーバー負荷が高い、リアルタイム性が若干劣る
クライアント → サーバー:リクエスト
↓ 定期的にリクエスト
クライアント ← サーバー:レスポンス
SSE(Server-Sent Events)
Pros
- HTTP/1.1 で動く、自動再接続が組み込まれている
- 実装がシンプル、プロキシを通過しやすい
Cons
- サーバー → クライアントの一方向のみ
- クライアントからのデータ送信は別途 HTTP が必要
クライアント → サーバー:接続確立(HTTP GET)
クライアント ← サーバー:一方向にストリームで送り続ける
比較
| Long Polling | SSE | WebSocket | |
|---|---|---|---|
| 通信方向 | 擬似双方向 | サーバー→クライアント | 完全双方向 |
| プロトコル | HTTP | HTTP | WS |
| 自動再接続 | 手動実装 | ブラウザ組み込み | 手動実装 |
| サーバー負荷 | 高 | 低 | 低 |
| プロキシ通過 | ◎ | ◎ | △(設定が必要な場合あり) |
| 実装コスト | 低 | 低 | 中 |
| 向いている用途 | 簡易通知 | 通知・ライブフィード | チャット・ゲーム・共同編集 |
基本実装
クライアント側(ブラウザ)
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}`)
})
- https://www.npmjs.com/package/url
- https://www.npmjs.com/package/jsonwebtoken
- https://developer.mozilla.org/ja/docs/Web/API/Storage/getItem
- https://developer.mozilla.org/ja/docs/Web/API/URLSearchParams
接続後の初回メッセージで認証する
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)
}
- https://www.npmjs.com/package/ioredis
- https://redis.io/docs/latest/commands/subscribe/
- https://redis.io/docs/latest/commands/publish/
- https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
┌─────────────────┐
│ 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)設定
UpgradeとConnection: Upgradeを正しく通す- 通常の HTTP タイムアウト設定のままだと、アイドル状態の WebSocket 接続が途中で切られることがある
- タイムアウト設定(read timeout や idle timeout)は長めに設定
参考
- WebSocket:https://datatracker.ietf.org/doc/html/rfc6455
- MDN WebSocket API:https://developer.mozilla.org/ja/docs/Web/API/WebSocket