Node.js WebSocket Server: The Real-Time Backend for IoT Dashboards
Your IoT data pipeline ends at an MQTT broker. Your dashboard starts at a WebSocket connection. The Node.js backend in between is where most teams make mistakes that cause memory leaks, reconnection storms, and inability to scale beyond a single server.
This guide builds a production WebSocket server that bridges MQTT telemetry to browser clients, handles authentication, implements device rooms, and scales horizontally with Redis pub/sub.
Why Not Just Expose MQTT Directly?
Technically possible, but problematic:
The WebSocket backend is your control layer.
Tech Stack
{
"ws": "^8.17.0",
"mqtt": "^5.8.0",
"ioredis": "^5.3.0",
"jsonwebtoken": "^9.0.0",
"express": "^4.19.0"
}
Step 1: Basic WebSocket Server with JWT Auth
import { WebSocketServer, WebSocket } from 'ws'
import { IncomingMessage } from 'http'
import jwt from 'jsonwebtoken'
import { parse } from 'url'interface AuthenticatedSocket extends WebSocket {
userId: string
deviceIds: string[]
isAlive: boolean
}
const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', (ws: AuthenticatedSocket, req: IncomingMessage) => {
// Extract JWT from query param or Authorization header
const { query } = parse(req.url ?? '', true)
const token = query.token as string
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string
deviceIds: string[]
}
ws.userId = payload.userId
ws.deviceIds = payload.deviceIds
ws.isAlive = true
} catch {
ws.close(4001, 'Unauthorized')
return
}
// Subscribe this client to their device rooms
for (const deviceId of ws.deviceIds) {
subscribeClientToDevice(ws, deviceId)
}
ws.on('message', (data) => handleClientMessage(ws, data))
ws.on('pong', () => { ws.isAlive = true })
ws.on('close', () => unsubscribeClient(ws))
ws.send(JSON.stringify({ type: 'connected', userId: ws.userId }))
})
// Heartbeat — detect dead connections every 30s
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
const client = ws as AuthenticatedSocket
if (!client.isAlive) {
unsubscribeClient(client)
return client.terminate()
}
client.isAlive = false
client.ping()
})
}, 30_000)
wss.on('close', () => clearInterval(heartbeat))
Step 2: Device Room Registry
// Device room: Map>
const deviceRooms = new Map>()function subscribeClientToDevice(ws: AuthenticatedSocket, deviceId: string) {
if (!deviceRooms.has(deviceId)) {
deviceRooms.set(deviceId, new Set())
}
deviceRooms.get(deviceId)!.add(ws)
}
function unsubscribeClient(ws: AuthenticatedSocket) {
for (const room of deviceRooms.values()) {
room.delete(ws)
}
}
function broadcastToDevice(deviceId: string, message: object) {
const room = deviceRooms.get(deviceId)
if (!room?.size) return
const payload = JSON.stringify(message)
for (const client of room) {
if (client.readyState === WebSocket.OPEN) {
client.send(payload)
}
}
}
Step 3: MQTT Bridge
import mqtt from 'mqtt'const mqttClient = mqtt.connect('mqtts://your-broker:8883', {
cert: readFileSync('./certs/client.crt'),
key: readFileSync('./certs/client.key'),
ca: readFileSync('./certs/ca.crt'),
rejectUnauthorized: true,
})
mqttClient.on('connect', () => {
// Subscribe to all device telemetry
mqttClient.subscribe('devices/+/telemetry', { qos: 1 })
mqttClient.subscribe('devices/+/status', { qos: 1 })
})
mqttClient.on('message', async (topic, payload) => {
// Extract device ID from topic: devices/{deviceId}/telemetry
const parts = topic.split('/')
const deviceId = parts[1]
const type = parts[2]
if (!deviceRooms.has(deviceId)) return // No subscribers, skip
const data = JSON.parse(payload.toString())
if (type === 'telemetry') {
// Enrich with device metadata from cache
const meta = await deviceMetaCache.get(deviceId)
broadcastToDevice(deviceId, {
type: 'telemetry',
deviceId,
payload: data,
meta,
serverTs: Date.now(),
})
}
if (type === 'status') {
broadcastToDevice(deviceId, {
type: 'device_status',
deviceId,
isOnline: data.online,
lastSeen: data.ts,
})
}
})
Step 4: Redis Pub/Sub for Horizontal Scaling
A single Node.js process handles ~50,000 concurrent WebSocket connections. For larger deployments or multi-region, use Redis to fan out across instances:
import Redis from 'ioredis'const publisher = new Redis(process.env.REDIS_URL!)
const subscriber = new Redis(process.env.REDIS_URL!)
// Subscribe to the Redis channel for device events
subscriber.subscribe('iot:telemetry')
subscriber.on('message', (_channel: string, message: string) => {
const { deviceId, payload } = JSON.parse(message)
broadcastToDevice(deviceId, payload) // Send to clients on THIS instance
})
// When MQTT receives data, publish to Redis (all instances receive it)
mqttClient.on('message', (topic, rawPayload) => {
const deviceId = topic.split('/')[1]
const payload = JSON.parse(rawPayload.toString())
publisher.publish('iot:telemetry', JSON.stringify({ deviceId, payload }))
})
With this pattern, a load balancer (sticky sessions not required) distributes WebSocket clients across 5 Node.js instances. All instances see all device events via Redis and forward to their local clients.
Step 5: Client-Initiated Commands
function handleClientMessage(ws: AuthenticatedSocket, data: Buffer) {
let msg: { type: string; deviceId: string; command: object }
try {
msg = JSON.parse(data.toString())
} catch {
return ws.send(JSON.stringify({ error: 'Invalid JSON' }))
} // Authorization: client can only command their own devices
if (!ws.deviceIds.includes(msg.deviceId)) {
return ws.send(JSON.stringify({ error: 'Forbidden' }))
}
if (msg.type === 'command') {
const topic = devices/${msg.deviceId}/commands
const payload = JSON.stringify(msg.command)
mqttClient.publish(topic, payload, { qos: 1 })
ws.send(JSON.stringify({ type: 'command_sent', deviceId: msg.deviceId }))
}
}
Connection State Management
Track server-side metrics for observability:
// Metrics endpoint
app.get('/metrics', (_req, res) => {
res.json({
connections: wss.clients.size,
deviceRooms: deviceRooms.size,
mqttConnected: mqttClient.connected,
uptime: process.uptime(),
memoryMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
})
})
Alert on: connection count growth > 20% per minute (reconnection storm), memory > 80% of limit (memory leak), MQTT disconnects.
Need this backend built for your IoT dashboard? [Contact Code Caracal](/contact) — we've shipped WebSocket servers handling 50,000+ concurrent dashboard connections.