Back to Blog
IoT Dashboard

Node.js WebSocket Server: The Real-Time Backend for IoT Dashboards

Building the WebSocket layer between your MQTT broker and React dashboard requires more than a simple ws server. This guide covers authenticated rooms, MQTT bridging, Redis pub/sub for horizontal scaling, and the connection patterns that handle 10,000 concurrent dashboard users.

October 10, 2024
12 min read
Node.jsWebSocketIoTMQTT

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:

  • 1. Security: Exposing MQTT broker publicly gives clients direct access to your device topics
  • 2. Filtering: Dashboard users should see only their own devices — MQTT policies alone can't enforce this
  • 3. Transformation: Raw MQTT payloads often need enrichment (device metadata, unit conversion)
  • 4. Aggregation: Mobile dashboards need less data than the backend processes
  • 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<deviceId, Set<WebSocket>>
    const deviceRooms = new Map<string, Set<AuthenticatedSocket>>()

    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.

    ⚡
    Written by CodeCaracal Engineering

    We write from production experience — every technique in our articles has been deployed to real clients. No academic theory.

    Let's Build Together

    Got an IoT challenge?
    We've shipped it.

    Whether you need a fleet to track, a factory to monitor, or a farm to automate — our team has done it before and we'd love to build it with you. Typical response time: under 24 hours.

    No upfront commitment99.9% uptime SLANDA on requestFixed-price options