If you have ever tried polling an HTTP server every few seconds from an ESP32, you know the pain: wasted bandwidth, sluggish response times, and a microcontroller that runs hot doing busywork. MQTT solves all of that. It is the de facto messaging protocol for IoT, used in everything from smart homes to industrial automation. In this guide, you will go from zero to a fully working MQTT system with an ESP32 publishing sensor data and subscribing to control commands.
What Is MQTT?
MQTT (Message Queuing Telemetry Transport) is a lightweight publish/subscribe messaging protocol designed for constrained devices and unreliable networks. Originally created by IBM in 1999 for monitoring oil pipelines over satellite links, it has since become the backbone of modern IoT communication.
Unlike HTTP's request/response model, MQTT uses a publish/subscribe pattern. Devices do not talk to each other directly. Instead, they communicate through a central broker that routes messages based on topics.
Key characteristics:
- Extremely lightweight — a minimal MQTT packet is just 2 bytes
- Persistent TCP connection — no overhead of repeated handshakes
- Bidirectional — devices can both send and receive data on the same connection
- Built-in reliability — three Quality of Service levels
- Works on constrained hardware — runs comfortably on an ESP32 with 520 KB of SRAM
Core MQTT Concepts
Before writing any code, you need to understand five fundamental concepts.
Broker
The broker is the central server that receives all messages from publishers and forwards them to subscribers. Think of it as a post office. Popular brokers include Mosquitto (open source), EMQX, and HiveMQ.
Client
Any device that connects to the broker is a client. Your ESP32, a Raspberry Pi, a Python script on your laptop, or a Home Assistant instance — they are all MQTT clients. A single client can both publish and subscribe simultaneously.
Topic
Topics are UTF-8 strings that the broker uses to route messages. They look like file paths:
home/livingroom/temperature
home/garden/pump/status
factory/line3/motor/rpm
Topics are hierarchical and case-sensitive. The / character separates levels.
Publish and Subscribe
A client publishes a message to a topic. Any client that has subscribed to that topic receives the message. The publisher does not know (or care) who receives it. This decoupling is one of MQTT's greatest strengths.
QoS Levels
MQTT defines three Quality of Service levels for message delivery:
| QoS | Name | Guarantee | Use Case |
|---|---|---|---|
| 0 | At most once | Fire and forget. No acknowledgment. | Frequent sensor readings (temperature every 5s). Missing one is fine. |
| 1 | At least once | Broker acknowledges. May deliver duplicates. | Important alerts, device status changes. |
| 2 | Exactly once | Four-step handshake. Guaranteed single delivery. | Billing events, critical commands. Rarely needed in hobby IoT. |
Practical guidance: Use QoS 0 for periodic sensor telemetry. Use QoS 1 for commands like turning a relay on/off. Avoid QoS 2 unless you have a specific reason — the overhead is significant on microcontrollers.
Why MQTT Over HTTP for IoT?
If you are coming from web development, your instinct might be to use HTTP APIs. Here is why MQTT is a better fit for IoT:
| Feature | MQTT | HTTP | WebSocket |
|---|---|---|---|
| Connection | Persistent TCP | New connection per request | Persistent TCP |
| Direction | Bidirectional | Client-initiated only | Bidirectional |
| Header overhead | 2 bytes minimum | 200-800 bytes per request | 2-14 bytes per frame |
| Pattern | Pub/Sub (1-to-many) | Request/Response (1-to-1) | Full duplex (1-to-1) |
| Power usage | Very low | High (repeated TLS handshakes) | Low |
| Offline handling | LWT + retained messages | Nothing built-in | Nothing built-in |
| Broker/server | Needed (Mosquitto, etc.) | Any HTTP server | Any WS server |
| Best for | Sensor networks, device control | REST APIs, web apps | Real-time dashboards |
The key advantage is persistent connection with minimal overhead. An ESP32 connects once and stays connected. Publishing a temperature reading costs about 20 bytes total. Doing the same over HTTPS would cost 500+ bytes per request plus a TLS handshake.
Setting Up an MQTT Broker
You need a broker before anything else. You have two options.
Option 1: Mosquitto on Raspberry Pi or Ubuntu
Mosquitto is the most popular open-source MQTT broker. It is lightweight enough to run on a Raspberry Pi Zero.
Install on Ubuntu / Raspberry Pi OS:
sudo apt update
sudo apt install -y mosquitto mosquitto-clients
Enable and start the service:
sudo systemctl enable mosquitto
sudo systemctl start mosquitto
Create a configuration file at /etc/mosquitto/conf.d/local.conf:
listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd
Create a user:
sudo mosquitto_passwd -c /etc/mosquitto/passwd iotuser
# Enter password when prompted
Restart Mosquitto:
sudo systemctl restart mosquitto
Test the broker using the built-in command-line clients. Open two terminal windows:
Terminal 1 (subscriber):
mosquitto_sub -h localhost -t "test/hello" -u iotuser -P yourpassword
Terminal 2 (publisher):
mosquitto_pub -h localhost -t "test/hello" -m "Hello from MQTT!" -u iotuser -P yourpassword
You should see "Hello from MQTT!" appear in Terminal 1 immediately.
Option 2: Free Cloud Brokers
If you do not want to self-host, these cloud brokers offer free tiers:
- HiveMQ Cloud — free for up to 100 connections. Provides TLS out of the box. Best for beginners.
- EMQX Cloud — free tier with 1 million session minutes per month.
- CloudMQTT — simple setup, good for prototyping.
For HiveMQ Cloud, sign up at console.hivemq.cloud, create a cluster, note down your host, port (8883 for TLS), and create credentials. You will use these in the ESP32 code below.
ESP32 MQTT Client: Publishing Sensor Data
Now for the hands-on part. We will use the PubSubClient library, which is the most widely used MQTT library for Arduino/ESP32.
Install Libraries in Arduino IDE
- Open Arduino IDE
- Go to Sketch > Include Library > Manage Libraries
- Search and install: PubSubClient by Nick O'Leary
- Search and install: DHT sensor library by Adafruit (for the DHT22 example later)
Basic MQTT Publisher
This sketch connects an ESP32 to WiFi, then to an MQTT broker, and publishes a message every 5 seconds:
#include <WiFi.h>
#include <PubSubClient.h>
// WiFi credentials
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
// MQTT broker
const char* mqtt_server = "192.168.1.100"; // Your broker IP
const int mqtt_port = 1883;
const char* mqtt_user = "iotuser";
const char* mqtt_pass = "yourpassword";
WiFiClient espClient;
PubSubClient client(espClient);
void setup_wifi() {
delay(10);
Serial.println("Connecting to WiFi...");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi connected. IP: " + WiFi.localIP().toString());
}
void reconnect() {
while (!client.connected()) {
Serial.println("Connecting to MQTT broker...");
// Generate a unique client ID
String clientId = "esp32-" + String(random(0xffff), HEX);
if (client.connect(clientId.c_str(), mqtt_user, mqtt_pass)) {
Serial.println("Connected to MQTT broker!");
} else {
Serial.print("Failed, rc=");
Serial.print(client.state());
Serial.println(" — retrying in 5 seconds");
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
setup_wifi();
client.setServer(mqtt_server, mqtt_port);
}
void loop() {
if (!client.connected()) {
reconnect();
}
client.loop();
// Publish a test message every 5 seconds
static unsigned long lastMsg = 0;
if (millis() - lastMsg > 5000) {
lastMsg = millis();
String payload = String(millis() / 1000);
client.publish("home/esp32/uptime", payload.c_str());
Serial.println("Published uptime: " + payload);
}
}
Upload this to your ESP32, open the Serial Monitor at 115200 baud, and you should see it connect and start publishing.
ESP32 Subscribing: Controlling a Relay
Subscribing lets your ESP32 receive commands. Here is how to control a relay module from an MQTT message:
#include <WiFi.h>
#include <PubSubClient.h>
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* mqtt_server = "192.168.1.100";
const int mqtt_port = 1883;
const char* mqtt_user = "iotuser";
const char* mqtt_pass = "yourpassword";
const int RELAY_PIN = 26; // GPIO connected to relay module IN pin
WiFiClient espClient;
PubSubClient client(espClient);
// Callback fires when a message arrives on a subscribed topic
void callback(char* topic, byte* payload, unsigned int length) {
String message;
for (unsigned int i = 0; i < length; i++) {
message += (char)payload[i];
}
Serial.println("Message on [" + String(topic) + "]: " + message);
if (String(topic) == "home/garden/pump/command") {
if (message == "ON") {
digitalWrite(RELAY_PIN, HIGH);
Serial.println("Relay ON");
// Publish status back so other clients know the state
client.publish("home/garden/pump/status", "ON", true); // retained
} else if (message == "OFF") {
digitalWrite(RELAY_PIN, LOW);
Serial.println("Relay OFF");
client.publish("home/garden/pump/status", "OFF", true); // retained
}
}
}
void setup_wifi() {
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
Serial.println("WiFi connected: " + WiFi.localIP().toString());
}
void reconnect() {
while (!client.connected()) {
String clientId = "esp32-relay-" + String(random(0xffff), HEX);
if (client.connect(clientId.c_str(), mqtt_user, mqtt_pass)) {
Serial.println("MQTT connected");
// Subscribe to the command topic
client.subscribe("home/garden/pump/command", 1); // QoS 1
} else {
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, LOW);
setup_wifi();
client.setServer(mqtt_server, mqtt_port);
client.setCallback(callback);
}
void loop() {
if (!client.connected()) {
reconnect();
}
client.loop();
}
Now you can turn the relay on and off from anywhere:
mosquitto_pub -h 192.168.1.100 -t "home/garden/pump/command" -m "ON" -u iotuser -P yourpassword
mosquitto_pub -h 192.168.1.100 -t "home/garden/pump/command" -m "OFF" -u iotuser -P yourpassword
Complete Example: DHT22 Sensor + Relay Control
This is the real-world sketch that combines everything. The ESP32 reads temperature and humidity from a DHT22 sensor every 30 seconds, publishes the data as JSON, subscribes to a relay control topic, and uses Last Will and Testament to signal when it goes offline.
Wiring:
- DHT22 data pin to GPIO 4 (with 10K pull-up resistor to 3.3V)
- Relay module IN pin to GPIO 26
- Relay module VCC to 5V, GND to GND
#include <WiFi.h>
#include <PubSubClient.h>
#include <DHT.h>
// ---- Configuration ----
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* mqtt_server = "192.168.1.100";
const int mqtt_port = 1883;
const char* mqtt_user = "iotuser";
const char* mqtt_pass = "yourpassword";
#define DHTPIN 4
#define DHTTYPE DHT22
#define RELAY_PIN 26
#define PUBLISH_INTERVAL 30000 // 30 seconds
// ---- Topic hierarchy ----
const char* TOPIC_TEMP = "home/livingroom/temperature";
const char* TOPIC_HUMID = "home/livingroom/humidity";
const char* TOPIC_DATA = "home/livingroom/sensor/json";
const char* TOPIC_RELAY = "home/livingroom/fan/command";
const char* TOPIC_STATUS = "home/livingroom/fan/status";
const char* TOPIC_LWT = "home/livingroom/device/status";
DHT dht(DHTPIN, DHTTYPE);
WiFiClient espClient;
PubSubClient client(espClient);
unsigned long lastPublish = 0;
void callback(char* topic, byte* payload, unsigned int length) {
String message;
for (unsigned int i = 0; i < length; i++) {
message += (char)payload[i];
}
Serial.printf("Received [%s]: %s\n", topic, message.c_str());
if (String(topic) == TOPIC_RELAY) {
if (message == "ON") {
digitalWrite(RELAY_PIN, HIGH);
client.publish(TOPIC_STATUS, "ON", true);
} else if (message == "OFF") {
digitalWrite(RELAY_PIN, LOW);
client.publish(TOPIC_STATUS, "OFF", true);
}
}
}
void setup_wifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nConnected! IP: %s\n", WiFi.localIP().toString().c_str());
}
void reconnect() {
while (!client.connected()) {
String clientId = "esp32-lr-" + String(random(0xffff), HEX);
// Last Will and Testament: broker publishes "offline" if we disconnect
if (client.connect(
clientId.c_str(),
mqtt_user, mqtt_pass,
TOPIC_LWT, // LWT topic
1, // LWT QoS
true, // LWT retain
"offline" // LWT message
)) {
Serial.println("MQTT connected");
// Announce we are online (retained)
client.publish(TOPIC_LWT, "online", true);
// Subscribe to relay commands
client.subscribe(TOPIC_RELAY, 1);
} else {
Serial.printf("MQTT failed (rc=%d), retrying in 5s\n", client.state());
delay(5000);
}
}
}
void publishSensorData() {
float temp = dht.readTemperature();
float humid = dht.readHumidity();
if (isnan(temp) || isnan(humid)) {
Serial.println("DHT22 read failed!");
return;
}
// Publish individual values
client.publish(TOPIC_TEMP, String(temp, 1).c_str(), true);
client.publish(TOPIC_HUMID, String(humid, 1).c_str(), true);
// Publish as JSON (useful for Home Assistant and Node-RED)
char json[128];
snprintf(json, sizeof(json),
"{\"temperature\":%.1f,\"humidity\":%.1f,\"uptime\":%lu}",
temp, humid, millis() / 1000);
client.publish(TOPIC_DATA, json, true);
Serial.printf("Published: %.1f C, %.1f %%\n", temp, humid);
}
void setup() {
Serial.begin(115200);
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, LOW);
dht.begin();
setup_wifi();
client.setServer(mqtt_server, mqtt_port);
client.setCallback(callback);
client.setBufferSize(256); // Increase buffer for JSON payloads
}
void loop() {
if (!client.connected()) {
reconnect();
}
client.loop();
if (millis() - lastPublish > PUBLISH_INTERVAL) {
lastPublish = millis();
publishSensorData();
}
}
Retained Messages and Last Will and Testament
Two features that make MQTT especially powerful for IoT:
Retained messages: When you publish with the retain flag set to true, the broker stores the last message on that topic. Any new subscriber immediately gets the latest value without waiting for the next publish cycle. In the code above, we retain sensor readings so that a dashboard connecting later instantly shows the current temperature.
Last Will and Testament (LWT): When a client connects, it can register a "will" message with the broker. If the client disconnects unexpectedly (power loss, network failure), the broker automatically publishes that will message. In our example, the ESP32 registers an LWT of "offline" on the device status topic. When it connects successfully, it publishes "online". If the ESP32 loses power, the broker publishes "offline" for it. This is how you detect dead devices.
Topic Hierarchy Best Practices
A well-designed topic structure scales gracefully. Follow these conventions:
{location}/{room}/{device_type}/{measurement_or_command}
Examples:
home/livingroom/temperature -- sensor value
home/livingroom/humidity -- sensor value
home/livingroom/fan/command -- send ON/OFF here
home/livingroom/fan/status -- device publishes state here
home/livingroom/device/status -- online/offline (LWT)
home/garden/pump/command
home/garden/soil/moisture
office/server-room/temperature
factory/line1/motor/rpm
factory/line1/motor/command
Wildcard subscriptions let you monitor entire hierarchies:
home/livingroom/#— all messages from the living roomhome/+/temperature— temperature from every room#— everything (use only for debugging)
+ matches one level. # matches all remaining levels and must be the last character.
Integrating with Home Assistant
Home Assistant has built-in MQTT support that makes it trivial to turn your ESP32 sensor data into dashboard widgets.
- In Home Assistant, go to Settings > Devices & Services > Add Integration > MQTT
- Enter your broker's IP, port, username, and password
- Add sensor entities in
configuration.yaml:
mqtt:
sensor:
- name: "Living Room Temperature"
state_topic: "home/livingroom/temperature"
unit_of_measurement: "C"
device_class: temperature
- name: "Living Room Humidity"
state_topic: "home/livingroom/humidity"
unit_of_measurement: "%"
device_class: humidity
switch:
- name: "Living Room Fan"
command_topic: "home/livingroom/fan/command"
state_topic: "home/livingroom/fan/status"
payload_on: "ON"
payload_off: "OFF"
retain: true
- Restart Home Assistant. Your sensors and switch will appear in the dashboard automatically.
Now you can create automations like "turn on the fan when temperature exceeds 30 degrees" entirely through the Home Assistant UI.
Building Automation Flows with Node-RED
Node-RED is a visual programming tool that is perfect for IoT automation. Install it alongside your broker:
sudo apt install -y nodejs npm
sudo npm install -g node-red
node-red
Open http://your-pi-ip:1880 in a browser. Drag in an mqtt in node, configure it with your broker details and topic home/livingroom/sensor/json, connect it to a json node to parse the payload, then connect to a switch node that checks if msg.payload.temperature > 32. Route the "true" output to an mqtt out node that publishes "ON" to home/livingroom/fan/command.
You have just built a visual automation: when the living room exceeds 32 degrees, the fan turns on automatically. No code required after the initial ESP32 setup.
Security: Protecting Your MQTT System
An unsecured MQTT broker is an open door to your network. Follow these steps:
1. Username and Password Authentication
We already set this up with mosquitto_passwd. Never run allow_anonymous true in production.
2. TLS Encryption
Without TLS, credentials and data travel in plain text. For Mosquitto:
# Generate a self-signed certificate (for local/private networks)
sudo openssl req -x509 -nodes -days 3650 \
-newkey rsa:2048 \
-keyout /etc/mosquitto/certs/server.key \
-out /etc/mosquitto/certs/server.crt
Update /etc/mosquitto/conf.d/local.conf:
listener 8883
cafile /etc/mosquitto/certs/server.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
allow_anonymous false
password_file /etc/mosquitto/passwd
On the ESP32 side, use WiFiClientSecure instead of WiFiClient:
#include <WiFiClientSecure.h>
WiFiClientSecure espClient;
PubSubClient client(espClient);
// For self-signed certs, you can skip verification (not ideal but workable)
espClient.setInsecure();
// Or load the CA certificate
// espClient.setCACert(ca_cert);
3. Access Control Lists (ACLs)
Create /etc/mosquitto/acl to restrict which users can access which topics:
user iotuser
topic readwrite home/#
user dashboard
topic read home/#
user admin
topic readwrite #
Add acl_file /etc/mosquitto/acl to your Mosquitto config.
Debugging with MQTT Explorer
MQTT Explorer is a free desktop tool (available for Windows, Mac, and Linux) that gives you a visual tree of every topic on your broker. It shows:
- All active topics in a tree hierarchy
- Message history with timestamps
- Retained message indicators
- Payload formatting (JSON, plain text, hex)
Download it from mqtt-explorer.com, enter your broker details, and connect. It is the single most useful debugging tool for MQTT development. You can see every message your ESP32 publishes in real time and manually publish test commands.
Scaling to Hundreds of Devices
When your IoT setup grows beyond a handful of devices, consider these strategies:
Topic namespacing: Prefix all topics with a project or site identifier:
wavtron/site-a/floor1/temperature
wavtron/site-a/floor2/temperature
wavtron/site-b/warehouse/humidity
Unique client IDs: Always generate unique client IDs. If two clients connect with the same ID, the broker disconnects the first one.
Bridge brokers: Mosquitto supports bridging, where one broker forwards messages to another. Use this to connect multiple sites:
# On site-b broker, add to mosquitto.conf:
connection site-a-bridge
address site-a-broker.example.com:8883
topic # both 1
remote_username bridgeuser
remote_password bridgepass
bridge_cafile /etc/mosquitto/certs/ca.crt
Persistent sessions: Set clean_session to false when connecting so the broker queues messages while a device is temporarily offline.
Rate limiting: Use Mosquitto's max_inflight_messages and max_queued_messages settings to prevent a misbehaving device from overwhelming the broker.
What You Will Need from Wavtron
To build the complete system described in this guide, here is the hardware list:
- ESP32 DevKit V1 — the microcontroller that runs the MQTT client
- DHT22 temperature and humidity sensor — for environmental monitoring
- 5V single-channel relay module — for controlling pumps, fans, or lights
- Jumper wires (male-to-female) — for connecting sensor and relay to ESP32
- Breadboard — for prototyping without soldering
- Micro USB cable — for programming and powering the ESP32
- Raspberry Pi 4 (optional) — to run Mosquitto broker and Node-RED locally
All of these are available at wavtron.in.
Next Steps
Once you are comfortable with the basics covered here, explore these directions:
- MQTT v5 features — shared subscriptions, message expiry, topic aliases
- ESP-IDF native MQTT — for production firmware, use the ESP-IDF MQTT client instead of Arduino PubSubClient for better performance and TLS support
- MQTT over WebSockets — connect browser dashboards directly to your broker
- InfluxDB + Grafana — store MQTT data in a time-series database and build professional monitoring dashboards
- OTA updates over MQTT — push firmware updates to ESP32 devices remotely
MQTT is one of those technologies that, once you understand it, completely changes how you approach IoT projects. The publish/subscribe model is elegant, the protocol is battle-tested, and the ecosystem of tools around it is mature. Start with the DHT22 example above, get messages flowing, and build from there.



