DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • How to Stream Sensor Data to Apache Pinot for Real Time Analysis
  • OPC-UA and MQTT: A Guide to Protocols, Python Implementations
  • A Comparative Analysis: AWS Kinesis vs Amazon Managed Streaming for Kafka - MSK
  • Connecting the Dots: Unraveling IoT Standards and Protocols

Trending

  • Building an Image Classification Pipeline With Apache Camel and Deep Java Library (DJL)
  • The Agent Protocol Stack: MCP vs. A2A vs. AG-UI
  • How AI Is Rewriting Full-Stack Java Systems: Practical Patterns with Spring Boot, Kafka and WebSockets
  • Content Lakes: Harness Unstructured Data for Enterprise AI Readiness
  1. DZone
  2. Data Engineering
  3. Big Data
  4. Building Enterprise-Grade Real-Time IoT Dashboards with Vue 3, MQTT, and Kafka

Building Enterprise-Grade Real-Time IoT Dashboards with Vue 3, MQTT, and Kafka

Event-driven architecture using MQTT (device communication) → Kafka (durable streams) → WebSocket (browser push) → Vue 3 (reactive UI).

By 
Venkata Sandeep Dhullipalla user avatar
Venkata Sandeep Dhullipalla
·
May. 26, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
247 Views

Join the DZone community and get the full member experience.

Join For Free

The convergence of IoT, real-time data streaming, and modern frontend frameworks is redefining how engineers build enterprise monitoring systems. Over the course of designing and leading the Device IoT Platform — an enterprise-grade solution for real-time monitoring, configuration, and diagnostics of thousands of distributed network devices — I encountered and solved a core architectural challenge: how do you build a frontend dashboard that can handle hundreds of concurrent device telemetry streams without sacrificing performance, maintainability, or user experience?

This article shares the architectural patterns, technology decisions, and hard-won lessons from that journey — covering the full stack from MQTT ingestion to Vue 3 reactivity to Kafka-backed event processing.

The Core Problem: Real-Time at Scale

Most developers are familiar with polling — periodically fetching data from an API endpoint. For IoT, polling is fundamentally broken:

  • Latency: A 5-second polling interval means 5 seconds of stale state.
  • Inefficiency: You're requesting data even when nothing has changed.
  • Scale: 1,000 devices × 1 request/5s = 200 requests/second just to read status — before any user interaction.

The solution is event-driven architecture: devices push telemetry when something changes, and the platform reacts. This requires a rethinking of both backend ingestion and frontend state management.

Architecture Overview

Plain Text
 
[IoT Devices]
     |
   MQTT Broker (Mosquitto / AWS IoT Core)
     |
[Node.js Telemetry Microservice]
     |
[Kafka Topic: device.telemetry.raw]
     |  (stream processor)
[Kafka Topic: device.telemetry.enriched]
     |
[WebSocket Server (Node.js)]
     |
[Vue 3 Dashboard Frontend]


Each layer has a distinct responsibility:

  • MQTT Broker handles lightweight publish/subscribe with devices using minimal overhead.
  • Node.js microservices bridge MQTT → Kafka, performing initial validation and normalization.
  • Kafka provides durable, replayable event streams — critical for audit trails and late-joining consumers.
  • WebSocket server fans out enriched telemetry to connected dashboard clients in real time.
  • Vue 3 handles reactive rendering, ensuring only the affected UI components re-render when new data arrives.

Backend: MQTT → Kafka Bridge in Node.js

The heart of the ingestion pipeline is a lightweight Node.js service using the mqtt and kafkajs libraries.

Plain Text
 
import mqtt from 'mqtt';
import { Kafka } from 'kafkajs';

const mqttClient = mqtt.connect(process.env.MQTT_BROKER_URL!, {
  clientId: `telemetry-bridge-${process.pid}`,
  username: process.env.MQTT_USERNAME,
  password: process.env.MQTT_PASSWORD,
  clean: true,
});

const kafka = new Kafka({ clientId: 'iot-bridge', brokers: [process.env.KAFKA_BROKER!] });
const producer = kafka.producer();

mqttClient.on('connect', async () => {
  await producer.connect();
  mqttClient.subscribe('devices/+/telemetry', { qos: 1 });
  console.log('MQTT → Kafka bridge active');
});

mqttClient.on('message', async (topic, payload) => {
  const deviceId = topic.split('/')[1];
  const data = JSON.parse(payload.toString());

  await producer.send({
    topic: 'device.telemetry.raw',
    messages: [
      {
        key: deviceId,
        value: JSON.stringify({ deviceId, timestamp: Date.now(), ...data }),
      },
    ],
  });
});


Key design decisions here:

  1. QoS Level 1 — ensures at-least-once delivery for telemetry messages. For command acknowledgments, we use QoS 2.
  2. Device ID as Kafka partition key — guarantees ordering per device while allowing parallel processing across partitions.
  3. Separation of raw vs. enriched topics — the device.telemetry.raw topic contains the bare payload; a downstream stream processor enriches it with device metadata, geolocation, and alert thresholds before publishing to device.telemetry.enriched.

WebSocket Fan-Out Server

The WebSocket layer subscribes to Kafka's enriched topic and pushes updates to connected browser clients. We use Kafka consumer groups to allow horizontal scaling of the WebSocket tier.

Plain Text
 
import { WebSocketServer } from 'ws';
import { Kafka } from 'kafkajs';

const wss = new WebSocketServer({ port: 8080 });
const kafka = new Kafka({ clientId: 'ws-fanout', brokers: [process.env.KAFKA_BROKER!] });
const consumer = kafka.consumer({ groupId: 'websocket-fanout-group' });

// Track subscriptions: deviceId → Set<WebSocket>
const deviceSubscriptions = new Map<string, Set<WebSocket>>();

wss.on('connection', (ws) => {
  ws.on('message', (msg) => {
    const { action, deviceId } = JSON.parse(msg.toString());
    if (action === 'subscribe') {
      if (!deviceSubscriptions.has(deviceId)) {
        deviceSubscriptions.set(deviceId, new Set());
      }
      deviceSubscriptions.get(deviceId)!.add(ws);
    }
  });

  ws.on('close', () => {
    deviceSubscriptions.forEach((clients) => clients.delete(ws));
  });
});

async function startKafkaConsumer() {
  await consumer.connect();
  await consumer.subscribe({ topic: 'device.telemetry.enriched' });

  await consumer.run({
    eachMessage: async ({ message }) => {
      const payload = JSON.parse(message.value!.toString());
      const clients = deviceSubscriptions.get(payload.deviceId);
      clients?.forEach((client) => {
        if (client.readyState === WebSocket.OPEN) {
          client.send(JSON.stringify(payload));
        }
      });
    },
  });
}

startKafkaConsumer();


This design enables selective subscription — a dashboard user viewing 50 devices only receives telemetry for those 50 devices, not the full firehose. This is critical for performance at scale.

Frontend: Vue 3 Reactive Architecture

The frontend is built with Vue 3 Composition API + Pinia for state management. The goal is to update only the UI components bound to a specific device when its telemetry arrives — not re-render the entire dashboard.

WebSocket Composable

Plain Text
 
// composables/useDeviceTelemetry.ts
import { ref, onMounted, onUnmounted } from 'vue';
import { useDeviceStore } from '@/stores/deviceStore';

export function useDeviceTelemetry(deviceIds: string[]) {
  const store = useDeviceStore();
  let ws: WebSocket | null = null;

  const connect = () => {
    ws = new WebSocket(import.meta.env.VITE_WS_URL);

    ws.onopen = () => {
      deviceIds.forEach((id) => {
        ws!.send(JSON.stringify({ action: 'subscribe', deviceId: id }));
      });
    };

    ws.onmessage = (event) => {
      const telemetry = JSON.parse(event.data);
      store.updateDeviceTelemetry(telemetry.deviceId, telemetry);
    };

    ws.onclose = () => {
      // Exponential backoff reconnection
      setTimeout(connect, Math.min(1000 * 2 ** reconnectAttempts++, 30000));
    };
  };

  onMounted(connect);
  onUnmounted(() => ws?.close());
}


Pinia Store with Fine-Grained Reactivity

Plain Text
 
// stores/deviceStore.ts
import { defineStore } from 'pinia';
import { reactive } from 'vue';

interface DeviceTelemetry {
  deviceId: string;
  status: 'online' | 'offline' | 'degraded';
  signalStrength: number;
  latency: number;
  lastSeen: number;
  alerts: string[];
}

export const useDeviceStore = defineStore('devices', () => {
  const telemetryMap = reactive<Record<string, DeviceTelemetry>>({});

  function updateDeviceTelemetry(deviceId: string, data: Partial<DeviceTelemetry>) {
    if (!telemetryMap[deviceId]) {
      telemetryMap[deviceId] = {} as DeviceTelemetry;
    }
    Object.assign(telemetryMap[deviceId], data);
  }

  return { telemetryMap, updateDeviceTelemetry };
});


Using reactive() with a map structure means Vue's dependency tracking is at the property level — a component subscribed to telemetryMap['device-001'].signalStrength won't re-render when device-002's data changes. This is the key to dashboard scalability.

Device Card Component

Plain Text
 
<!-- components/DeviceCard.vue -->
<template>
  <div class="device-card" :class="statusClass">
    <div class="device-header">
      <span class="device-id">{{ deviceId }}</span>
      <StatusBadge :status="telemetry?.status" />
    </div>
    <div class="metrics">
      <MetricBar label="Signal" :value="telemetry?.signalStrength" unit="dBm" />
      <MetricBar label="Latency" :value="telemetry?.latency" unit="ms" />
    </div>
    <AlertList :alerts="telemetry?.alerts ?? []" />
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useDeviceStore } from '@/stores/deviceStore';

const props = defineProps<{ deviceId: string }>();
const store = useDeviceStore();

// Only this device's slice of state — targeted re-renders only
const telemetry = computed(() => store.telemetryMap[props.deviceId]);

const statusClass = computed(() => ({
  'status-online': telemetry.value?.status === 'online',
  'status-offline': telemetry.value?.status === 'offline',
  'status-degraded': telemetry.value?.status === 'degraded',
}));
</script>


Performance Optimizations

1. Virtual Scrolling for Large Device Lists

When monitoring 500+ devices, rendering all device cards simultaneously tanks performance. We use vue-virtual-scrollerto only render visible cards:

Plain Text
 
<RecycleScroller
  class="device-list"
  :items="filteredDevices"
  :item-size="120"
  key-field="deviceId"
  v-slot="{ item }"
>
  <DeviceCard :device-id="item.deviceId" />
</RecycleScroller>


2. Debounced Batch Updates

Devices can emit bursts of telemetry. Updating the Pinia store on every single message causes excessive re-renders. We batch incoming messages within a 100ms window:

Plain Text
 
let pendingUpdates: Record<string, Partial<DeviceTelemetry>> = {};
let batchTimeout: ReturnType<typeof setTimeout> | null = null;

function queueUpdate(deviceId: string, data: Partial<DeviceTelemetry>) {
  pendingUpdates[deviceId] = { ...(pendingUpdates[deviceId] ?? {}), ...data };

  if (!batchTimeout) {
    batchTimeout = setTimeout(() => {
      Object.entries(pendingUpdates).forEach(([id, update]) => {
        store.updateDeviceTelemetry(id, update);
      });
      pendingUpdates = {};
      batchTimeout = null;
    }, 100);
  }
}


3. Lazy Loading and Code Splitting

Device diagnostic panels (charts, event logs, configuration editors) are loaded on demand:

Plain Text
 
const DeviceDiagnostics = defineAsyncComponent(
  () => import('@/components/DeviceDiagnostics.vue')
);


Combined with route-level code splitting, the initial bundle stays under 200KB gzipped.

Security Architecture: OAuth 2.0 + RBAC

Device management platforms require fine-grained access control. Not every user should be able to issue firmware update commands to production devices.

JWT Claims-Based RBAC

We encode role information directly in the JWT access token:

Plain Text
 
{
  "sub": "user-123",
  "roles": ["device:read", "device:configure"],
  "scope": "region:us-east",
  "exp": 1699999999
}


The frontend reads these claims to conditionally render action buttons, and the backend validates them on every API call:

Plain Text
 
// middleware/rbac.ts
export function requirePermission(permission: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    const token = req.headers.authorization?.split(' ')[1];
    const decoded = verifyJWT(token!);

    if (!decoded.roles.includes(permission)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

// Route definition
router.post('/devices/:id/firmware', requirePermission('device:firmware'), handleFirmwareUpdate);


Deployment: CI/CD on AWS

The entire platform is containerized and deployed via a GitLab CI/CD pipeline to AWS ECS with Fargate.

Plain Text
 
# .gitlab-ci.yml (excerpt)
stages:
  - test
  - build
  - deploy

build-and-push:
  stage: build
  script:
    - docker build -t $ECR_REGISTRY/iot-frontend:$CI_COMMIT_SHA .
    - docker push $ECR_REGISTRY/iot-frontend:$CI_COMMIT_SHA

deploy-production:
  stage: deploy
  script:
    - aws ecs update-service
        --cluster iot-platform
        --service frontend
        --force-new-deployment
  environment: production
  only:
    - main


Blue-green deployments ensure zero downtime for this 24/7 critical infrastructure platform.

Results and Key Metrics

After migrating from a polling-based architecture to this event-driven stack:

  • Dashboard latency: reduced from 5–10 seconds (polling) to under 200ms (WebSocket push).
  • Backend API load: reduced by ~78% — telemetry pushes replaced constant polling.
  • Frontend bundle size: kept under 220KB gzipped through lazy loading and tree-shaking.
  • Throughput: validated at 10,000 concurrent telemetry events/second through Kafka partitioning.

Conclusion

Building a real-time IoT dashboard at enterprise scale requires rethinking the entire data flow — from device communication protocols through streaming pipelines to fine-grained frontend reactivity. The combination of MQTT for lightweight device communication, Kafka for durable event streaming, WebSockets for real-time push to browsers, and Vue 3's targeted reactivity model creates a system that scales gracefully without sacrificing developer ergonomics.

The patterns described here — selective WebSocket subscriptions, batched Pinia updates, virtual scrolling, and JWT-based RBAC — have been validated in production on a platform serving critical network infrastructure. They are broadly applicable to any domain requiring real-time monitoring at scale: energy management, fleet tracking, industrial automation, and beyond.

Github: Real-Time-IoT-Dashboards-Vue-3-MQTT-Kafka

IoT MQTT Vue.js kafka

Opinions expressed by DZone contributors are their own.

Related

  • How to Stream Sensor Data to Apache Pinot for Real Time Analysis
  • OPC-UA and MQTT: A Guide to Protocols, Python Implementations
  • A Comparative Analysis: AWS Kinesis vs Amazon Managed Streaming for Kafka - MSK
  • Connecting the Dots: Unraveling IoT Standards and Protocols

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook