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).
Join the DZone community and get the full member experience.
Join For FreeThe 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
[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.
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:
- QoS Level 1 — ensures at-least-once delivery for telemetry messages. For command acknowledgments, we use QoS 2.
- Device ID as Kafka partition key — guarantees ordering per device while allowing parallel processing across partitions.
- Separation of raw vs. enriched topics — the
device.telemetry.rawtopic contains the bare payload; a downstream stream processor enriches it with device metadata, geolocation, and alert thresholds before publishing todevice.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.
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
// 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
// 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
<!-- 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:
<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:
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:
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:
{
"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:
// 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.
# .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.
Opinions expressed by DZone contributors are their own.
Comments