SSE vs MQTT for IoT Dashboards Permalink to this section

Part of SSE vs WebSockets vs HTTP Polling, under SSE Protocol Fundamentals & Architecture.

IoT systems overwhelmingly use MQTT between devices and the cloud: it is light, works on constrained hardware and unreliable networks, and brokers are built for millions of devices. When the time comes to show that data in a browser dashboard, there are two options. The browser can connect to the MQTT broker directly over WebSockets, or a backend can subscribe to MQTT and serve the dashboard over Server-Sent Events. Both work; they differ sharply in security, scaling and how much the browser needs to know about the device network. This guide compares them.

Symptom & Developer Intent Permalink to this section

  • The dashboard connects to the broker with credentials embedded in the frontend.
  • A browser subscribed to a wildcard topic receives far more data than it displays.
  • Retained messages are needed to show device state on page load, but the dashboard shows nothing until the next reading.
  • The broker’s connection count is dominated by dashboard users rather than devices.
  • The security team asks why browsers can publish to device command topics.

The intent is a dashboard that shows current device state immediately, receives only what it displays, cannot touch device topics it should not, and does not load the device broker with human sessions.

Root Cause Analysis Permalink to this section

MQTT’s topic and permission model was designed for devices, each with its own identity and a narrow set of topics. Browser users map onto it awkwardly: per-user broker credentials must be issued and rotated, topic ACLs must be kept in sync with application permissions, and every browser tab is a broker connection.

Two ways to get device data to a browser Two panels comparing direct MQTT over WebSockets from the browser with an SSE bridge that subscribes to MQTT on the server. Two ways to get device data to a browser Browser → MQTT over WS broker credentials per user topic ACLs mirror app perms every tab = broker connection can publish unless blocked MQTT → server → SSE app auth, app permissions server filters and shapes one broker subscription per node read-only by construction
The direct path exposes the broker to browsers. The bridge keeps devices and humans on separate protocols and separate permission systems.

MQTT does offer features dashboards want: retained messages give a new subscriber the last value on a topic immediately, and QoS levels control delivery guarantees. An SSE bridge must reproduce the first (a snapshot on connect) and decides the second on its own terms (ids and replay).

Step-by-Step Resolution Permalink to this section

Step 1 — Decide which path fits Permalink to this section

Choosing a path for an IoT dashboard Decision tree for choosing direct MQTT over WebSockets or an SSE bridge, based on audience, need to send commands and scale of dashboard users. Choosing a path for an IoT dashboard Internal tool, trusted operators? MQTT over WebSockets yes no Customer-facing, per-user permissions? SSE bridge yes no Thousands of concurrent viewers? SSE bridge yes no SSE bridge
Internal tools with few users can talk MQTT directly. Customer-facing dashboards almost always belong behind a bridge.

Direct MQTT is reasonable for small internal tools where operators already have broker accounts. For customer-facing products, the bridge keeps the device network private and uses the application’s existing authentication and authorisation.

Step 2 — Build the bridge: one subscription per node, fan-out locally Permalink to this section

// bridge.js — subscribe once per node, keep the latest state per device, fan out over SSE.
import mqtt from 'mqtt';

const latest = new Map();                           // deviceId → last reading (retained-like)
const client = mqtt.connect(process.env.MQTT_URL, { clientId: `sse-bridge-${process.env.HOSTNAME}` });
client.subscribe('fleet/+/telemetry', { qos: 1 });

client.on('message', (topic, payload) => {
  const deviceId = topic.split('/')[1];
  const reading = { deviceId, ...JSON.parse(payload.toString()), at: Date.now() };
  latest.set(deviceId, reading);
  hub.publish(deviceId, reading);                   // to streams watching this device
});

app.get('/api/fleet/stream', requireSession, async (req, res) => {
  const devices = await permissions.devicesFor(req.user);   // app-level authorisation
  openStream(res);
  // Snapshot first: the SSE equivalent of retained messages.
  const snapshot = devices.map((d) => latest.get(d)).filter(Boolean);
  res.write(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`);
  const off = hub.subscribeMany(devices, (r) => res.write(`event: reading\ndata: ${JSON.stringify(r)}\n\n`));
  req.on('close', off);
});

The bridge subscribes with a wildcard once per node, keeps the latest reading per device in memory (seeded from retained messages when it connects), and streams only the devices each user may see.

Step 3 — Conflate high-rate telemetry for the screen Permalink to this section

Devices may report many times per second; dashboards update at most a few times per second. Keep the latest reading per device and flush on a timer, as in conflating price ticks for slow clients. Alarms and state changes that must not be missed travel as a separate event type with ids and replay.

Step 4 — Send commands through the API, never from the browser to the broker Permalink to this section

app.post('/api/devices/:id/commands', requireSession, async (req, res) => {
  if (!(await permissions.canCommand(req.user, req.params.id))) return res.sendStatus(403);
  client.publish(`fleet/${req.params.id}/commands`, JSON.stringify(validate(req.body)), { qos: 1 });
  res.sendStatus(202);
});

The browser never holds broker credentials, and every command passes through validation, authorisation and audit logging.

Broker connections for 200,000 devices and 8,000 dashboard viewers Bar chart comparing total broker connections with browsers connecting directly over MQTT and with an SSE bridge of ten nodes. Broker connections for 200,000 devices and 8,000 dashboard viewers Direct MQTT from browsers 208,000 SSE bridge (10 nodes) 200,010 connections held by the MQTT broker
With a bridge, the broker's connection count is devices plus a handful of bridge nodes. Human viewers load the web tier, which is built for them.

Step 5 — Keep the bridge’s view complete across restarts Permalink to this section

A bridge node that restarts has an empty latest map until devices report again, which for slow-reporting devices may be many minutes. Seed it on startup: MQTT delivers retained messages to a new subscription immediately, so publish device state as retained messages (or have a state service that does), and the bridge’s first moments after subscribe refill the map. For fleets where retained messages are not used, load the last known state from the time-series store at startup. Either way, never let a restarted bridge serve an empty snapshot as if devices had no state — send a loading flag in the snapshot until the map is warm, and let the dashboard show a skeleton rather than blank tiles.

MQTT shared subscriptions ($share/group/topic) divide messages among subscribers, which is the wrong model for a fan-out tier in the same way Kafka consumer groups are: every bridge node needs every message for the devices its users watch. Use ordinary subscriptions per node, and if the fleet is large, partition by topic prefix and route users to the bridge nodes that hold their devices’ partitions.

Validation & Monitoring Permalink to this section

# The bridge serves a snapshot immediately, then live readings.
curl -sN -b s.txt https://app.example.com/api/fleet/stream | head -4

# Publish a test reading and confirm it arrives.
mosquitto_pub -h broker -t fleet/dev-42/telemetry -m '{"temp":21.4}'

Monitor the bridge’s MQTT subscription health (connection state, message rate), the lag between device timestamp and SSE write, and per-user stream counts. Alert if the bridge disconnects from the broker, since dashboards would silently freeze.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Is MQTT over WebSockets bad practice?

No. It is well supported and reasonable for internal tools. For public dashboards it pushes broker credentials and topic permissions into the browser, which a bridge avoids.

How do I replicate retained messages with SSE?

Keep the latest value per device in the bridge — seeded from retained messages when it subscribes — and send it as a snapshot event at the start of every stream.

What about QoS 2 guarantees?

QoS applies between devices and the broker. Between the bridge and the browser, use event ids and replay for anything that must not be lost, and conflation for readings where only the latest matters.

Does the bridge add latency?

One extra hop inside the data centre, typically a few milliseconds — far less than the network path to the browser and invisible on a dashboard.