Building a simple WebSocket server is easy. Building one that handles 10,000 concurrent connections across multiple servers is not. Here's what you need to know.
Raw WebSocket Server
import { WebSocketServer } from 'ws'
const wss = new WebSocketServer({ port: 8080 })
const clients = new Map() // connectionId -> WebSocket
wss.on('connection', (ws, req) => {
const connectionId = crypto.randomUUID()
const userId = getUserFromToken(req) // Extract from auth header
clients.set(connectionId, { ws, userId })
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString())
handleMessage(connectionId, userId, message)
} catch (e) {
ws.send(JSON.stringify({ error: 'Invalid JSON' }))
}
})
ws.on('close', () => {
clients.delete(connectionId)
})
ws.on('error', (err) => {
console.error(`WebSocket error for ${connectionId}:`, err.message)
clients.delete(connectionId)
})
// Send acknowledgment
ws.send(JSON.stringify({ type: 'connected', connectionId }))
})
Authentication
import { verify } from 'jsonwebtoken'
function getUserFromToken(req: IncomingMessage): string | null {
// Token can be in Authorization header or query param
const token = req.headers['authorization']?.replace('Bearer ', '')
|| new URL(req.url!, 'http://x').searchParams.get('token')
if (!token) return null
try {
const decoded = verify(token, process.env.JWT_SECRET!) as { userId: string }
return decoded.userId
} catch {
return null
}
}
wss.on('connection', (ws, req) => {
const userId = getUserFromToken(req)
if (!userId) {
ws.close(1008, 'Unauthorized')
return
}
// Continue with authenticated connection
})
Heartbeat / Ping-Pong
Browser and OS don't always close WebSocket connections cleanly. Detect dead connections with ping-pong:
const HEARTBEAT_INTERVAL = 30_000
function setupHeartbeat(ws: WebSocket, connectionId: string) {
let isAlive = true
ws.on('pong', () => { isAlive = true })
const interval = setInterval(() => {
if (!isAlive) {
ws.terminate()
clients.delete(connectionId)
clearInterval(interval)
return
}
isAlive = false
ws.ping()
}, HEARTBEAT_INTERVAL)
ws.on('close', () => clearInterval(interval))
}
Horizontal Scaling with Redis Pub/Sub
A single WebSocket server can't reach connections on other servers. Use Redis to broadcast across instances:
import { createClient } from 'redis'
const publisher = createClient({ url: process.env.REDIS_URL })
const subscriber = createClient({ url: process.env.REDIS_URL })
await publisher.connect()
await subscriber.connect()
// Subscribe to messages from other servers
await subscriber.subscribe('broadcast', (message) => {
const { channel, data, excludeConnectionId } = JSON.parse(message)
for (const [connId, { ws, userId }] of clients) {
if (connId === excludeConnectionId) continue
if (channel && !userSubscribedTo(userId, channel)) continue
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data))
}
}
})
// Broadcast to all connections across all servers
async function broadcastToAll(data: object, excludeConnectionId?: string) {
await publisher.publish('broadcast', JSON.stringify({
channel: null,
data,
excludeConnectionId
}))
}
// Broadcast to specific channel subscribers
async function broadcastToChannel(channel: string, data: object) {
await publisher.publish('broadcast', JSON.stringify({ channel, data }))
}
Client-Side Reconnection
class ReconnectingWebSocket {
private ws: WebSocket | null = null
private reconnectDelay = 1000
private maxDelay = 30000
constructor(private url: string, private onMessage: (data: any) => void) {
this.connect()
}
private connect() {
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
console.log('Connected')
this.reconnectDelay = 1000 // Reset on success
}
this.ws.onmessage = (event) => {
this.onMessage(JSON.parse(event.data))
}
this.ws.onclose = () => {
console.log(`Disconnected. Reconnecting in ${this.reconnectDelay}ms`)
setTimeout(() => this.connect(), this.reconnectDelay)
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay)
}
this.ws.onerror = (err) => {
console.error('WebSocket error:', err)
this.ws?.close()
}
}
send(data: object) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data))
}
}
}
Exponential backoff prevents thundering herd when your server restarts.
Connection Limits
const MAX_CONNECTIONS = 10_000
wss.on('connection', (ws, req) => {
if (clients.size >= MAX_CONNECTIONS) {
ws.close(1013, 'Server overloaded')
return
}
// ...
})
A single Node.js process can handle ~10,000-50,000 WebSocket connections depending on message volume. Beyond that, run multiple processes behind a load balancer with sticky sessions or the Redis pub/sub pattern above.