The complete guide to connecting ESP32 firmware to an MQTT broker, implementing QoS levels, securing with TLS, and streaming live data to a React dashboard โ built from a real irrigation monitor deployment.
Most IoT developers default to HTTP out of habit. It works โ but heavyweight headers and polling overhead constrain battery-powered field devices. MQTT was designed from scratch for exactly this problem.
2-byte minimum header. ESP32 publishes 500 messages per hour on a single LiPo charge โ impossible with REST/HTTP polling cycles.
One TCP connection stays open. No handshake overhead per message โ ideal for high-frequency sensor telemetry where every millisecond matters.
Retained messages and persistent sessions mean the broker buffers data when the ESP32 loses signal and replays on reconnect โ no data loss.
Fire-and-forget, at-least-once, or exactly-once delivery โ matched per-topic to the criticality of each data stream.
MQTT is broker-mediated: the ESP32 publishes sensor readings to a named topic; the React dashboard subscribes and receives data instantly. This decoupling makes scaling from one device to thousands trivially easy โ without changing firmware or frontend code.
For the irrigation monitor we built for agricultural clients, ESP32 nodes transmitted soil moisture, temperature, and pump status every 30 seconds to a Mosquitto broker, with a React dashboard surfacing live readings. Latency from sensor read to dashboard update consistently measured under 200ms โ unachievable with polling.
Three distinct layers, each independently scalable. Understanding the boundary between them separates hobby projects from production systems.
Firmware reads sensors via ADC / IยฒC / SPI. PubSubClient handles MQTT. WiFiClientSecure wraps TLS. Reconnect logic with exponential backoff prevents broker floods on WiFi recovery.
Routes published messages to subscribers. Enforces authentication and ACL. Supports WebSocket listeners for browser clients. Retains last-known-good messages per topic.
MQTT.js connects over WebSocket (WSS). Custom useMqtt hook streams messages into React state. Recharts renders live time-series charts with sub-second refresh.
Two libraries do the heavy lifting: PubSubClient for MQTT and WiFiClientSecure for TLS. Here's the production pattern we use across all ESP32 deployments.
// ESP32 MQTT client with TLS โ DigitalMonk production pattern
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
const char* MQTT_BROKER = "mqtt.yourbroker.io";
const int MQTT_PORT = 8883; // TLS port
const char* ROOT_CA = R"(
-----BEGIN CERTIFICATE-----
... your broker CA cert here ...
-----END CERTIFICATE-----
)";
WiFiClientSecure wifiClient;
PubSubClient mqttClient(wifiClient);
const char* PUB_TOPIC = "farm/01/zone/03/soil";
const char* CMD_TOPIC = "farm/01/zone/03/pump/cmd";
void mqttCallback(char* topic, byte* payload, unsigned int len) {
String msg = String((char*)payload).substring(0, len);
if (msg == "ON") digitalWrite(PUMP_PIN, HIGH);
if (msg == "OFF") digitalWrite(PUMP_PIN, LOW);
}
void reconnect() {
int delay_ms = 1000;
while (!mqttClient.connected()) {
if (mqttClient.connect("esp32_zone03", "user", "pass")) {
mqttClient.subscribe(CMD_TOPIC, 1);
} else {
delay(delay_ms);
delay_ms = min(delay_ms * 2, 30000); // exponential backoff
}
}
}
void publishSensorData() {
StaticJsonDocument<128> doc;
doc["moisture"] = readSoilMoisture();
doc["temp_c"] = readTemperature();
doc["pump_on"] = pumpActive;
doc["ts"] = millis();
char buf[128]; serializeJson(doc, buf);
mqttClient.publish(PUB_TOPIC, buf, true); // retained=true
}
void setup() {
wifiClient.setCACert(ROOT_CA);
mqttClient.setServer(MQTT_BROKER, MQTT_PORT);
mqttClient.setCallback(mqttCallback);
mqttClient.setKeepAlive(60);
}
void loop() {
if (!mqttClient.connected()) reconnect();
mqttClient.loop();
static unsigned long last = 0;
if (millis() - last > 30000) { publishSensorData(); last = millis(); }
}The retained=true flag on publish stores the last reading on the broker. Any new subscriber โ including the React dashboard on first load โ immediately receives current state without waiting for the next publish cycle.
The exponential backoff in reconnect() is critical at fleet scale. Without it, 50 nodes recovering simultaneously create a thundering herd that overwhelms the broker. Backoff staggers reconnection naturally, capping at 30 seconds.
QoS is MQTT's delivery contract. Not all sensor data deserves the same treatment โ getting this wrong either wastes bandwidth or silently drops critical messages.
Published once, no acknowledgement, no retry. If the network drops, the message is lost. Lowest latency and overhead.
Publisher retries until it receives a PUBACK. May produce duplicate messages โ subscribers must handle idempotency.
Four-part handshake guarantees exactly one delivery. Highest overhead โ reserve for when duplicates cause real consequences.
In the irrigation monitor: routine readings (moisture, temperature) at QoS 0 โ data refreshes every 30 seconds anyway. Pump commands at QoS 1 โ we need confidence the command arrived, and duplicates are handled gracefully in firmware. Water usage billing events used QoS 2 โ duplicates there would cause double-charging.
MQTT topics are free-form strings โ teams either design them deliberately or end up with an unmaintainable mess six months later.
| Topic Path | Direction | QoS | Retained | Purpose |
|---|---|---|---|---|
| farm/{id}/zone/{z}/soil | PUBLISH | 0 | โ | Soil moisture + temperature JSON, every 30s |
| farm/{id}/zone/{z}/pump/cmd | COMMAND | 1 | โ | Pump ON/OFF from dashboard to device |
| farm/{id}/zone/{z}/pump/state | PUBLISH | 1 | โ | Confirmed pump state reported back by ESP32 |
| farm/{id}/zone/{z}/status | PUBLISH | 1 | โ | Device heartbeat + Last Will Testament |
| farm/{id}/alerts | PUBLISH | 2 | โ | Threshold breach alerts for SMS / email |
| farm/{id}/ota/update | COMMAND | 2 | โ | OTA firmware URL pushed to device fleet |
The pump/cmd and pump/state split is deliberate โ commands flow down, state flows up. The wildcard + matches one level; # matches all remaining. Subscribe to farm/# once to capture everything for a time-series database writer โ no firmware changes when new zones are added.
Unencrypted MQTT on port 1883 exposes sensor data, pump commands, and credentials to anyone on the network path.
Cloud brokers (HiveMQ Cloud, AWS IoT Core) provide a root CA. Self-hosted Mosquitto requires OpenSSL. Embed it in ESP32 flash โ it verifies the broker's identity and prevents man-in-the-middle attacks.
For development, wifiClient.setInsecure() skips cert verification โ fast to set up, never for production. For production, pass the CA cert via setCACert() and optionally add client certs for mutual TLS (mTLS).
Port 1883 is plaintext TCP. Port 8883 is the IANA-assigned port for MQTT over TLS. Your broker must have TLS listening on 8883, and mqttClient.setServer() must reference it accordingly.
Every ESP32 authenticates with a unique client ID, username, and password โ or ideally a per-device X.509 certificate. This lets you revoke a single compromised device without disrupting the fleet.
Browsers cannot open raw TCP sockets, so the broker exposes a WebSocket endpoint on port 8084 (WSS for TLS). MQTT.js handles the rest.
import { useState, useEffect, useRef } from 'react';
import mqtt from 'mqtt';
export function useMqtt(brokerUrl, topics) {
const [messages, setMessages] = useState({});
const [status, setStatus] = useState('connecting');
const clientRef = useRef(null);
useEffect(() => {
const client = mqtt.connect(brokerUrl, {
username: process.env.REACT_APP_MQTT_USER,
password: process.env.REACT_APP_MQTT_PASS,
rejectUnauthorized: true,
reconnectPeriod: 3000,
});
client.on('connect', () => {
setStatus('connected');
topics.forEach(t => client.subscribe(t, { qos: 1 }));
});
client.on('message', (topic, payload) => {
try {
const data = JSON.parse(payload.toString());
setMessages(prev => ({ ...prev, [topic]: { ...data, receivedAt: new Date() } }));
} catch (e) {}
});
client.on('offline', () => setStatus('reconnecting'));
clientRef.current = client;
return () => client.end();
}, [brokerUrl]);
return { messages, status };
}
// Usage โ wildcard catches all zones automatically
const { messages } = useMqtt('wss://broker.io:8084/mqtt', ['farm/01/+/soil']);
const moisture = messages['farm/01/zone/03/soil']?.moisture ?? '--';The wildcard subscription farm/01/+/soil captures data from all zones in one subscribe call. New zones appear automatically as their topics start publishing โ the dashboard never changes when new hardware is added in the field.
The broker is the only component with real availability requirements โ extended downtime means data loss. Choose based on your operational context.
Open-source, self-hosted on a $5/month VPS. Full control over config, persistence, and TLS. Ideal for cost-sensitive projects or data sovereignty. Requires ops overhead.
Managed cloud broker with generous free tier (100 connections). Native WebSocket support. TLS out of the box. Zero ops overhead. Our default for greenfield projects under 1,000 devices.
Enterprise-grade managed broker. Per-message billing. Deep AWS integration โ Lambda triggers, DynamoDB rules, Kinesis streams. Best when your stack already lives in AWS.
ESP32 nodes across field zones publishing soil moisture, temperature, and pump status every 30 seconds to Mosquitto. A React dashboard consumed live topic streams via WebSocket. Automation logic subscribed on the same broker, triggering pump commands the moment moisture dipped below threshold โ no cloud round-trip, no polling delay.
Reconnect logic looks simple until 200 ESP32 nodes recover simultaneously from a 10-minute WiFi outage. Without coordinated backoff, the broker receives a connection storm exceeding its maximum client limits, causing cascading failures. Production firmware adds jitter to backoff intervals, per-device reconnect windows, and broker-side connection rate limiting.
Topic access control matters at scale. A misconfigured device publishing to farm/# instead of its own zone can flood the broker's message queue and starve other subscribers. Broker ACL rules constrain each device to the topics it legitimately owns โ enforced server-side.
Data persistence is separate from MQTT delivery guarantees. QoS 2 gets the message to the broker exactly once โ it says nothing about what happens afterward. Time-series data requires a dedicated subscriber writing to InfluxDB, TimescaleDB, or AWS Timestream. Without it you show live data but lose all historical context.
From ESP32 firmware to React frontend, DigitalMonk handles the full stack โ production-grade MQTT architecture from day one.