A real-time IoT dashboard isn’t simply a web page with charts. It’s a complete system involving data ingestion, processing, storage, transmission and rendering, all optimised so that data reaches the user’s eyes in under a second from the sensor.
In this guide I explain how to build one from scratch, based on real experience building them for clients like Spherag (24/7 agricultural monitoring), InfoAdex (55M+ advertising records) and Orquest (workforce management).
Requirements of a real IoT dashboard
Before choosing technologies, define what your dashboard needs:
Functional
- Display data updated in real time (< 1 second latency)
- Visualise time series with zoom and navigation
- Visual alerts when a value goes out of range
- Multiple data sources in a unified view
- Filters by device, location, time range
- Data export (CSV, PDF)
Non-functional
- Run 24/7 without performance degradation
- Support hundreds of widgets updating simultaneously
- Render thousands of data points without blocking the UI
- Automatic reconnection if connection is lost
- Responsive: work on control room screens and mobiles
- Initial load time < 3 seconds
Recommended tech stack
After iterating on multiple projects, this is the stack we recommend for a real-time IoT dashboard:
Frontend
| Technology | Purpose |
|---|---|
| React 18+ | UI framework with Concurrent Features |
| Recharts or Visx | Performant time-series charts |
| D3.js | Complex custom visualisations |
| TanStack Query | Server state management + cache |
| Socket.IO or native WebSocket | Real-time data |
| Tailwind CSS | Fast, consistent styling |
| Zustand | Lightweight global state |
Backend
| Technology | Purpose |
|---|---|
| Node.js + Fastify | REST API + WebSocket server |
| Redis | Cache + pub/sub for broadcast |
| TimescaleDB | Time-series database |
| PostgreSQL | Metadata, configurations, users |
| MQTT broker (EMQX/Mosquitto) | Device data reception |
Infrastructure
| Technology | Purpose |
|---|---|
| Docker + Docker Compose | Local development and deployment |
| Kubernetes | Scalable production |
| Nginx | Reverse proxy + WebSocket upgrade |
| Prometheus + Grafana | Dashboard self-monitoring |
Data architecture
End-to-end data flow
IoT Device
↓ (MQTT)
MQTT Broker
↓ (subscription)
Ingestion service (Node.js)
↓ ↓
TimescaleDB Redis pub/sub
(storage) (real-time)
↓ ↓
REST API WebSocket server
↓ ↓
Dashboard (historical data + live data)
Separating historical from live data
This is the key pattern. The dashboard needs two different data flows:
- Historical data: When the user loads the page or changes the time range, stored data is queried from TimescaleDB via REST API
- Live data: Once the view is loaded, new data arrives via WebSocket and is appended to the chart without reload
This allows the dashboard to show months of history with efficient zoom AND update in real time with new data.
Step 1: MQTT data ingestion
The first component is a service that subscribes to the MQTT broker and processes incoming messages.
// ingestion-service.js (Node.js)
import mqtt from 'mqtt';
import { pool } from './db.js';
import { redis } from './redis.js';
const client = mqtt.connect(process.env.MQTT_BROKER_URL, {
username: process.env.MQTT_USER,
password: process.env.MQTT_PASS,
clean: false,
clientId: 'dashboard-ingestion-01'
});
client.subscribe('devices/+/telemetry', { qos: 1 });
client.on('message', async (topic, payload) => {
const deviceId = topic.split('/')[1];
const data = JSON.parse(payload.toString());
// 1. Store in TimescaleDB
await pool.query(
`INSERT INTO telemetry (device_id, timestamp, temperature, humidity, battery)
VALUES ($1, NOW(), $2, $3, $4)`,
[deviceId, data.temperature, data.humidity, data.battery]
);
// 2. Publish to Redis for WebSocket broadcast
await redis.publish('realtime:telemetry', JSON.stringify({
deviceId,
timestamp: Date.now(),
...data
}));
// 3. Evaluate alert rules
await evaluateAlerts(deviceId, data);
});
This service does three things with each message: stores it for historical queries, publishes it to Redis for the WebSocket server to transmit to connected dashboards, and evaluates whether it triggers any alert.
Step 2: WebSocket server
The WebSocket server maintains persistent connections with dashboards and sends new data in real time.
// websocket-server.js
import { WebSocketServer } from 'ws';
import { redis } from './redis.js';
const wss = new WebSocketServer({ port: 8080 });
const subscriber = redis.duplicate();
await subscriber.subscribe('realtime:telemetry');
subscriber.on('message', (channel, message) => {
const data = JSON.parse(message);
wss.clients.forEach(client => {
if (client.readyState === 1) {
client.send(message);
}
});
});
wss.on('connection', (ws, req) => {
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
if (!verifyToken(token)) {
ws.close(4001, 'Unauthorized');
return;
}
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
// Periodic ping to detect dead connections
setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
WebSocket vs Server-Sent Events (SSE)
- WebSocket: Bidirectional. Use it if the dashboard sends commands to the server (dynamic filters, device control)
- SSE: Unidirectional server-to-client. Simpler, works better with HTTP proxies. Use it if the dashboard only receives data
For most IoT dashboards, SSE is sufficient and simpler to implement. WebSocket is needed when there’s frequent bidirectional interaction.
Step 3: REST API for historical data
The dashboard needs an API to load historical data when the user changes the time range or applies filters.
// api.js (Fastify)
app.get('/api/telemetry/:deviceId', async (req, reply) => {
const { deviceId } = req.params;
const { from, to } = req.query;
// Automatic downsampling based on time range
const bucket = calculateBucket(from, to);
const result = await pool.query(`
SELECT
time_bucket($1, timestamp) AS time,
AVG(temperature) as temperature,
AVG(humidity) as humidity,
MIN(battery) as battery
FROM telemetry
WHERE device_id = $2
AND timestamp BETWEEN $3 AND $4
GROUP BY time
ORDER BY time ASC
`, [bucket, deviceId, from, to]);
return result.rows;
});
function calculateBucket(from, to) {
const diffHours = (new Date(to) - new Date(from)) / 3600000;
if (diffHours <= 1) return '1 minute';
if (diffHours <= 24) return '5 minutes';
if (diffHours <= 168) return '1 hour';
if (diffHours <= 720) return '6 hours';
return '1 day';
}
The downsampling trick is fundamental: when viewing a year of data, you don’t need 525,600 points (one per minute). You need ~365 points (one per day). TimescaleDB has native time_bucket that does this efficiently.
Step 4: React frontend
Real-time data hook
// useRealtimeData.js
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
export function useRealtimeData(deviceId) {
const queryClient = useQueryClient();
const wsRef = useRef(null);
useEffect(() => {
const ws = new WebSocket(
`${WS_URL}?token=${getToken()}&device=${deviceId}`
);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
queryClient.setQueryData(
['telemetry', deviceId],
(old) => old ? [...old.slice(-999), data] : [data]
);
};
ws.onclose = () => {
setTimeout(() => reconnect(), getBackoff());
};
wsRef.current = ws;
return () => ws.close();
}, [deviceId]);
}
Chart component with live data
// TimeSeriesChart.jsx
import { useQuery } from '@tanstack/react-query';
import { LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts';
import { useRealtimeData } from './useRealtimeData';
function TimeSeriesChart({ deviceId, metric, from, to }) {
const { data: historicalData } = useQuery({
queryKey: ['telemetry', deviceId, from, to],
queryFn: () => fetchTelemetry(deviceId, from, to),
});
useRealtimeData(deviceId);
return (
<LineChart data={historicalData} width={800} height={300}>
<XAxis dataKey="time" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey={metric}
stroke="#5dd3b3"
dot={false}
isAnimationActive={false}
/>
</LineChart>
);
}
Note the isAnimationActive={false}: in dashboards with frequent updates, transition animations block rendering and degrade performance.
Step 5: Alerting system
// alerts.js
async function evaluateAlerts(deviceId, data) {
const rules = await getAlertRules(deviceId);
for (const rule of rules) {
const value = data[rule.metric];
let triggered = false;
switch (rule.condition) {
case 'above': triggered = value > rule.threshold; break;
case 'below': triggered = value < rule.threshold; break;
case 'equals': triggered = value === rule.threshold; break;
}
if (triggered) {
await createAlert({
deviceId,
ruleId: rule.id,
value,
threshold: rule.threshold,
severity: rule.severity,
timestamp: Date.now()
});
await notify(rule.channels, {
device: deviceId,
metric: rule.metric,
value,
threshold: rule.threshold
});
}
}
}
Performance optimisations
1. Update throttling
If a device sends data every 100ms, you don’t need to update the dashboard 10 times per second. Batch updates:
const buffer = new Map();
subscriber.on('message', (channel, message) => {
const data = JSON.parse(message);
buffer.set(data.deviceId, data);
});
setInterval(() => {
if (buffer.size > 0) {
const batch = Array.from(buffer.values());
broadcast(JSON.stringify({ type: 'batch', data: batch }));
buffer.clear();
}
}, 200);
2. List virtualisation
If you have 1,000 devices, don’t render 1,000 rows. Use virtualisation (react-window or react-virtual) to render only what’s visible.
3. Web Workers for heavy processing
If you need to calculate statistics or detect anomalies on the frontend, do it in a Web Worker to avoid blocking the main thread.
4. Canvas instead of SVG for many points
Recharts uses SVG by default, which degrades with more than 5,000 points. For high-resolution time series, use a Canvas-based library (like uPlot or lightweight-charts).
Deployment and operations
Docker Compose for development
services:
mqtt:
image: emqx/emqx:latest
ports: ["1883:1883", "8083:8083"]
timescaledb:
image: timescale/timescaledb:latest-pg16
environment:
POSTGRES_PASSWORD: dev
ports: ["5432:5432"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
ingestion:
build: ./services/ingestion
depends_on: [mqtt, timescaledb, redis]
api:
build: ./services/api
ports: ["3000:3000"]
depends_on: [timescaledb, redis]
dashboard:
build: ./frontend
ports: ["5173:5173"]
Self-monitoring
Use Prometheus to monitor:
- Active WebSocket connections
- TimescaleDB write latency
- MQTT messages processed per second
- API response time
- WebSocket server memory usage
Alternatives: Grafana vs Custom
Use Grafana if:
- The dashboard is for internal technical use
- TimescaleDB/InfluxDB is your primary data source
- You don’t need custom UX
- You want to be operational in days, not weeks
Build custom if:
- The dashboard is for end users
- You need specific branding and UX
- You have complex interactions (device commands, workflows)
- You need functionality Grafana doesn’t support
In many projects, the best solution is both: Grafana for the ops team and a custom dashboard for business users.
Conclusion
Building a real-time IoT dashboard isn’t trivial, but with the right stack and appropriate patterns (historical/real-time separation, downsampling, throttling), you can have a robust and performant system.
Key takeaways:
- Separate historical data (REST API + TimescaleDB) from live data (WebSocket + Redis pub/sub)
- Implement automatic downsampling based on time range
- Use throttling to avoid saturating the frontend with updates
- Design the alerting system as an independent component
- Monitor the dashboard itself as you’d monitor any production system
If you need help building your real-time dashboard or your industrial IoT platform, at Soamee we’ve been doing it for years. Book a free consultation and let’s look at your case.