Skip to content

WebSockets - Full-Duplex Communication Over TCP

Table of Contents


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