WebSockets - Full-Duplex Communication Over TCP¶
Table of Contents¶
- WebSocket Protocol : ws:// vs wss://
- ws Library Basics
- Broadcasting Messages to Multiple Clients
- WebSocket vs HTTP : When to Use Each
- Security : CSWSH , Origin Check , Rate Limiting
WebSocket Protocol : ws:// vs wss://¶
WebSocket starts as an HTTP upgrade request - the client asks the server to switch protocols:
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
The server responds with a 101 Switching Protocols , then the connection stays open for bidirectional messages
ws:// is unencrypted (like HTTP). wss:// wraps WebSocket in TLS (like HTTPS). Never use ws:// in production unless you enjoy having your chat messages displayed on a billboard
ws Library Basics¶
npm install ws
Echo server:
const http = require('node:http')
const WebSocket = require('ws')
const fs = require('node:fs')
// HTTP server to share (WS upgrades from HTTP)
const server = http.createServer((req, res) => {
res.writeHead(200)
res.end('WebSocket server running')
})
const wss = new WebSocket.Server({ server })
wss.on('connection', (ws, req) => {
console.log(`Client connected from ${req.socket.remoteAddress}`)
// send welcome
ws.send(JSON.stringify({ type: 'system', message: 'Connected to WebSocket server' }))
// receive messages
ws.on('message', (data) => {
console.log('Received:', data.toString())
// echo back
ws.send(`Echo: ${data}`)
})
// handle client disconnect
ws.on('close', (code, reason) => {
console.log(`Client disconnected - code: ${code}, reason: ${reason.toString()}`)
})
// handle errors
ws.on('error', (err) => {
console.error('WebSocket error:', err.message)
})
})
server.listen(8080, () => {
console.log('WebSocket server hanging out on ws://localhost:8080')
})
Client side (browser):
const ws = new WebSocket('ws://localhost:8080')
ws.onopen = () => {
console.log('Connected to server')
ws.send('Hello from the browser')
}
ws.onmessage = (event) => {
console.log('Server says:', event.data)
}
ws.onclose = () => console.log('Disconnected')
ws.onerror = (err) => console.error('WS Error:', err)
// send structured data
ws.send(JSON.stringify({ action: 'join', room: 'general' }))
HTTPS/WSS server:
const server = https.createServer({
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem')
}, handler)
const wss = new WebSocket.Server({ server })
// client connects via wss://yourserver.com
ws options you should know:
const wss = new WebSocket.Server({
server,
maxPayload: 1024 * 100, // 100KB max message size
backlog: 512, // max pending connections
clientTracking: true, // track connected clients (wss.clients)
verifyClient: verifyFunction // custom auth on upgrade
})
Broadcasting Messages to Multiple Clients¶
Simple broadcast to all connected clients:
function broadcast(wss, message) {
const data = typeof message === 'string' ? message : JSON.stringify(message)
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(data)
}
})
}
wss.on('connection', (ws) => {
// notify everyone when someone joins
broadcast(wss, { type: 'system', message: 'A wild client appeared' })
})
Room-based broadcasting:
const rooms = new Map() // roomName -> Set<WebSocket>
function joinRoom(ws, roomName) {
if (!rooms.has(roomName)) {
rooms.set(roomName, new Set())
}
rooms.get(roomName).add(ws)
}
function leaveRoom(ws, roomName) {
const room = rooms.get(roomName)
if (room) {
room.delete(ws)
if (room.size === 0) rooms.delete(roomName)
}
}
function broadcastToRoom(roomName, message, excludeWs = null) {
const room = rooms.get(roomName)
if (!room) return
const data = JSON.stringify(message)
room.forEach(client => {
if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
client.send(data)
}
})
}
// usage
wss.on('connection', (ws) => {
ws.on('message', (data) => {
try {
const msg = JSON.parse(data)
switch (msg.type) {
case 'join':
joinRoom(ws, msg.room)
broadcastToRoom(msg.room, { type: 'system', text: 'User joined room' })
break
case 'message':
broadcastToRoom(msg.room, { type: 'chat', user: msg.user, text: msg.text }, ws)
// send confirmation to sender
ws.send(JSON.stringify({ type: 'ack', id: msg.id }))
break
case 'leave':
leaveRoom(ws, msg.room)
broadcastToRoom(msg.room, { type: 'system', text: 'User left room' })
break
}
} catch (e) {
ws.send(JSON.stringify({ type: 'error', text: 'Invalid message format' }))
}
})
ws.on('close', () => {
// cleanup - remove from all rooms
rooms.forEach((clients, room) => {
if (clients.has(ws)) {
clients.delete(ws)
broadcastToRoom(room, { type: 'system', text: 'User disconnected' })
if (clients.size === 0) rooms.delete(room)
}
})
})
})
Client receives:
// server connection
const ws = new WebSocket('wss://yourserver.com/ws')
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'system') {
console.log('[System]', msg.text)
} else if (msg.type === 'chat') {
console.log(`[${msg.user}]`, msg.text)
} else if (msg.type === 'ack') {
console.log('Message confirmed:', msg.id)
}
}
WebSocket vs HTTP : When to Use Each¶
| Aspect | HTTP | WebSocket |
|---|---|---|
| Connection | Short-lived , request-response | Long-lived , persistent |
| Communication | Client -> Server only | Bidirectional |
| Overhead | Headers on every request (800+ bytes) | Minimal framing (2-14 bytes) |
| Real-time | Polling (latency + overhead) | Instant push |
| Caching | Yes (CDN , browser) | No |
| Scaling | Load balance any way | Sticky sessions or adapter |
| Browser Support | Everywhere | WebSocket API (IE11+) |
Use HTTP when: * CRUD operations on resources (REST API) * Cacheable responses * Request/response patterns * One-off operations
Use WebSocket when: * Real-time updates (chat , notifications) * Live data feeds (stock prices , crypto tickers) * Collaborative editing (Google Docs style) * Gaming , multiplayer * Streaming data pipelines
The hybrid pattern: REST for the CRUD , WebSocket for the real-time push:
// REST creates a resource
app.post('/api/messages', (req, res) => {
const message = db.saveMessage(req.body)
// push to connected clients
broadcastToRoom(message.room, { type: 'chat', ...message })
res.status(201).json(message)
})
Security : CSWSH , Origin Check , Rate Limiting¶
Cross-Site WebSocket Hijacking (CSWSH) - the WebSocket version of CSRF. An attacker's page opens a WebSocket to your server using the victim's existing session cookies
// VULNERABLE - trusts cookies for authentication
wss.on('connection', (ws, req) => {
const cookies = parseCookies(req.headers.cookie)
const user = sessionStore.get(cookies.session_id)
// attacker's page can use the victim's cookies!
})
Mitigation 1 - Origin header check:
const wss = new WebSocket.Server({
server,
verifyClient: (info, cb) => {
const origin = info.origin || info.req.headers.origin
const allowedOrigins = ['https://yourapp.com', 'https://admin.yourapp.com']
if (!origin || !allowedOrigins.includes(origin)) {
console.warn(`Rejected connection from origin: ${origin}`)
cb(false, 403, 'Origin not allowed')
return
}
cb(true)
}
})
Mitigation 2 - Token-based auth (recommended for production):
const wss = new WebSocket.Server({
server,
verifyClient: (info, cb) => {
// client sends token as query parameter
// ws://server.com/ws?token=abc123
const url = new URL(info.req.url, `http://${info.req.headers.host}`)
const token = url.searchParams.get('token')
if (!token) {
cb(false, 401, 'Authentication required')
return
}
try {
const user = verifyToken(token)
// attach user to request for later use
info.req.user = user
cb(true)
} catch {
cb(false, 403, 'Invalid token')
}
}
})
// later in connection handler
wss.on('connection', (ws, req) => {
const user = req.user // authenticated user from verifyClient
console.log(`User ${user.name} connected`)
})
Client sends token:
// browser
const token = await getAuthToken()
const ws = new WebSocket(`wss://yourserver.com/ws?token=${token}`)
Rate limiting WebSocket messages:
const messageCounts = new Map()
function rateLimitWS(ws, maxMessages = 60, windowMs = 60000) {
const key = ws._socket.remoteAddress
const now = Date.now()
if (!messageCounts.has(key)) {
messageCounts.set(key, [])
ws._rateLimitTimer = setInterval(() => {
const timestamps = messageCounts.get(key)
if (timestamps) {
const recent = timestamps.filter(t => t > Date.now() - windowMs)
if (recent.length === 0) {
clearInterval(ws._rateLimitTimer)
messageCounts.delete(key)
} else {
messageCounts.set(key, recent)
}
}
}, windowMs)
}
const timestamps = messageCounts.get(key)
timestamps.push(now)
// keep only within window
const recent = timestamps.filter(t => t > now - windowMs)
messageCounts.set(key, recent)
return recent.length <= maxMessages
}
// usage in message handler
wss.on('connection', (ws) => {
ws.on('message', (data) => {
if (!rateLimitWS(ws)) {
ws.send(JSON.stringify({ type: 'error', text: 'Rate limit exceeded' }))
return
}
// process message...
})
})
Additional WebSocket security rules: * Always use wss:// in production * Set maxPayload to prevent memory exhaustion (ws default is ~1MB) * Close connections that send invalid frames (ws handles this , but verify) * Set connection timeouts for idle clients * Don't trust req.url in WebSocket connections - validate it
const wss = new WebSocket.Server({
server,
maxPayload: 65536, // 64KB max message
verifyClient: (info, cb) => {
// only allow connections to /ws path
const path = new URL(info.req.url, 'http://localhost').pathname
if (path !== '/ws') {
cb(false, 404)
return
}
cb(true)
}
})
prerequisites¶
web_08_restful.md - REST API patterns , request/response handling , auth flows
next => No next section - this is the end of the Web/HTTP series