Skip to main content
Back to blog
IoT Dashboard WebSocket React

How to build a real-time IoT dashboard

Step-by-step tutorial for building a real-time IoT dashboard. Tech stack, WebSocket, time-series data, React, alerting and data architecture.

JM
Javier Manzano
CEO & Co-founder • June 12, 2026

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

After iterating on multiple projects, this is the stack we recommend for a real-time IoT dashboard:

Frontend

TechnologyPurpose
React 18+UI framework with Concurrent Features
Recharts or VisxPerformant time-series charts
D3.jsComplex custom visualisations
TanStack QueryServer state management + cache
Socket.IO or native WebSocketReal-time data
Tailwind CSSFast, consistent styling
ZustandLightweight global state

Backend

TechnologyPurpose
Node.js + FastifyREST API + WebSocket server
RedisCache + pub/sub for broadcast
TimescaleDBTime-series database
PostgreSQLMetadata, configurations, users
MQTT broker (EMQX/Mosquitto)Device data reception

Infrastructure

TechnologyPurpose
Docker + Docker ComposeLocal development and deployment
KubernetesScalable production
NginxReverse proxy + WebSocket upgrade
Prometheus + GrafanaDashboard 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:

  1. Historical data: When the user loads the page or changes the time range, stored data is queried from TimescaleDB via REST API
  2. 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:

  1. Separate historical data (REST API + TimescaleDB) from live data (WebSocket + Redis pub/sub)
  2. Implement automatic downsampling based on time range
  3. Use throttling to avoid saturating the frontend with updates
  4. Design the alerting system as an independent component
  5. 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.

Don't miss a thing

JM

Javier Manzano

CEO & Co-founder at Soamee

Passionate about technology and software development. Sharing knowledge and experiences to help other developers grow.

Did you enjoy this article?

If you need help with your development project, we are here for you.

Book a free call →