ESP32 MQTT tutorial: publish and subscribe with PubSubClient
- 1 ESP32 GPIO explained: inputs, outputs, pull-ups and interrupts
- 2 How to use ESP32 timers: stop using delay() in your firmware
- 3 ESP32 MQTT tutorial: publish and subscribe with PubSubClient
- 4 ESP32 FreeRTOS: tasks, queues and semaphores in plain English
- 5 ESP32 watchdog timer: build firmware that recovers from crashes
- 6 ESP32 OTA updates: push firmware wirelessly in production
Most MQTT tutorials stop at client.publish("topic", "hello world") and call it done. That’s enough to get an LED blinking in a demo. It is nowhere near enough for a device that’s going to sit in a panel box for three years and be expected to keep reporting. This post covers what those tutorials skip: robust reconnection, QoS semantics, Last Will and Testament, and the subtleties of the PubSubClient library that will bite you in production.
Why MQTT and not REST
If you’re coming from web development, your instinct might be to use HTTP. Your ESP32 makes a POST to an API endpoint every 30 seconds. Simple, familiar, stateless.
Here’s why that’s wrong for IoT:
| HTTP REST | MQTT | |
|---|---|---|
| Connection model | New TCP connection per request | Persistent TCP connection |
| Overhead per message | ~500 bytes (headers) | ~2 bytes minimum |
| Server push | Polling or long-polling | Native (subscribe model) |
| QoS | None (fire-and-forget) | 0, 1, or 2 |
| Offline buffering | No | Yes (broker queues retained/persistent) |
| Power consumption | High (TCP handshake each time) | Low (persistent connection) |
| Bidirectional | Request-response only | Full pub/sub |
MQTT is a publish/subscribe protocol. Devices publish messages to topics (strings like sensors/room-01/temperature). Other devices or servers subscribe to topics and receive those messages. The broker sits in the middle and routes messages. No device talks directly to another — they’re fully decoupled through the broker.
This matters for embedded systems because:
- The broker handles all the complexity of routing to multiple subscribers
- Devices can subscribe to receive commands without polling
- The broker can queue messages for offline devices (with persistence configured)
Broker options
For development, run Mosquitto locally:
# Ubuntu/Debian
sudo apt install mosquitto mosquitto-clients
# Start with a minimal config that allows anonymous connections
cat > /tmp/mosquitto-dev.conf << 'EOF'
listener 1883
allow_anonymous true
EOF
mosquitto -c /tmp/mosquitto-dev.conf
# Test in another terminal
mosquitto_sub -h localhost -t "test/#" -v &
mosquitto_pub -h localhost -t "test/device1" -m "hello"
For production, your options:
| Broker | Best for | Notes |
|---|---|---|
| Mosquitto (self-hosted) | Full control, no SaaS dependency | You manage auth, TLS, backups |
| EMQX (self-hosted) | High scale, enterprise features | More complex to configure |
| HiveMQ Cloud (free tier) | Prototyping, up to 100 connections | 10GB/month data limit |
| AWS IoT Core | AWS-integrated production | Per-message pricing, IAM auth |
| CloudMQTT | Small production | Simple to set up |
For this post, I’ll use a local Mosquitto instance. The PubSubClient code works identically against any broker — you just change the host and port.
WiFi connection with reconnect
Before MQTT, you need WiFi. The wrong way:
// WRONG: assumes WiFi connects once and stays connected forever
void setup() {
WiFi.begin(SSID, PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
// proceed to MQTT setup
}
WiFi drops. Access points reboot. DHCP leases expire. A device that can’t recover from a dropped WiFi connection isn’t production-ready. The right approach handles reconnection at every layer:
#include <WiFi.h>
const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASSWORD = "your-password";
bool ensureWiFi() {
if (WiFi.status() == WL_CONNECTED) {
return true;
}
Serial.printf("[WiFi] Disconnected. Reconnecting to %s...\n", WIFI_SSID);
WiFi.disconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
uint32_t startMs = millis();
while (WiFi.status() != WL_CONNECTED) {
if (millis() - startMs > 15000) {
Serial.println("[WiFi] Connection timeout after 15s");
return false;
}
vTaskDelay(pdMS_TO_TICKS(250));
}
Serial.printf("[WiFi] Connected. IP: %s\n", WiFi.localIP().toString().c_str());
return true;
}
This function is called before every MQTT operation attempt, not just once in setup().
PubSubClient setup
Add to platformio.ini:
lib_deps =
knolleary/PubSubClient @ ^2.8
Or in Arduino IDE: Library Manager → search “PubSubClient” by Nick O’Leary.
There’s a critical default you need to change immediately:
#include <PubSubClient.h>
// PubSubClient default max packet size is 256 bytes.
// If you're publishing JSON payloads larger than this, messages silently fail.
// Set this BEFORE including PubSubClient, or configure it via setBufferSize().
// Option 1: define before include (affects all instances)
#define MQTT_MAX_PACKET_SIZE 1024
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
Or call mqtt.setBufferSize(1024) in setup. If you forget this and your JSON payload is 260 bytes, mqtt.publish() returns false with no useful error message. This is the most common PubSubClient gotcha.
Connecting to the broker with Last Will and Testament
LWT (Last Will and Testament) is a message the broker publishes on your behalf if your connection drops unexpectedly (without a clean DISCONNECT). It’s how other systems know a device went offline without being told.
const char* MQTT_HOST = "192.168.1.100";
const int MQTT_PORT = 1883;
const char* MQTT_CLIENT_ID = "esp32-sensor-01";
const char* LWT_TOPIC = "devices/sensor-01/status";
const char* LWT_PAYLOAD = "{\"online\":false}";
bool ensureMQTT() {
if (mqtt.connected()) {
return true;
}
Serial.printf("[MQTT] Connecting to %s:%d...\n", MQTT_HOST, MQTT_PORT);
// Connect with LWT: if connection drops unexpectedly, broker publishes LWT
// Parameters: clientId, user, pass, willTopic, willQoS, willRetain, willMessage
bool connected = mqtt.connect(
MQTT_CLIENT_ID,
nullptr, // username (null if no auth)
nullptr, // password
LWT_TOPIC,
1, // QoS 1 for LWT
true, // retain: broker keeps LWT for future subscribers
LWT_PAYLOAD
);
if (!connected) {
Serial.printf("[MQTT] Connect failed, rc=%d\n", mqtt.state());
return false;
}
Serial.println("[MQTT] Connected");
// Publish online status (retained, overwrites the LWT if present)
mqtt.publish(LWT_TOPIC, "{\"online\":true}", true);
// Re-subscribe to command topics (subscriptions don't survive reconnect)
mqtt.subscribe("devices/sensor-01/commands", 1);
return true;
}
The MQTT state codes from mqtt.state():
| Code | Constant | Meaning |
|---|---|---|
| -4 | MQTT_CONNECTION_TIMEOUT | Server didn’t respond in time |
| -3 | MQTT_CONNECTION_LOST | Network connection dropped |
| -2 | MQTT_CONNECT_FAILED | Network connection failed |
| -1 | MQTT_DISCONNECTED | Client is disconnected |
| 0 | MQTT_CONNECTED | Connected |
| 1 | MQTT_CONNECT_BAD_PROTOCOL | Wrong protocol version |
| 2 | MQTT_CONNECT_BAD_CLIENT_ID | Client ID rejected |
| 3 | MQTT_CONNECT_UNAVAILABLE | Server unavailable |
| 4 | MQTT_CONNECT_BAD_CREDENTIALS | Wrong username/password |
| 5 | MQTT_CONNECT_UNAUTHORIZED | Client not authorized |
Log these in production — they tell you exactly what’s failing.
QoS levels explained
| QoS | Delivery guarantee | How it works | Use case |
|---|---|---|---|
| 0 | At most once (fire and forget) | No acknowledgment | High-frequency telemetry where loss is acceptable |
| 1 | At least once | Broker ACKs; sender retries until ACKed | Important data that must arrive (may duplicate) |
| 2 | Exactly once | Four-way handshake | Critical commands, financial data |
QoS 0 is the default and right for most sensor telemetry. If you’re publishing temperature every 5 seconds, losing one reading is fine. Use QoS 1 for commands (turn relay on/off) where you need confirmation it arrived. QoS 2 is rarely needed in embedded systems — the overhead is significant and most brokers have limitations on QoS 2 at scale.
Note: PubSubClient supports QoS 0 and QoS 1 for publishing. QoS 2 is not supported. If you need QoS 2, you’ll need a different library (Arduino-MQTT or async-mqtt-client).
Retained messages
A retained message is stored by the broker. When a new client subscribes to that topic, it immediately receives the most recent retained message. This is how your dashboard knows a device’s state when it first loads, without waiting for the next publish.
// Retained: last value available to new subscribers immediately
mqtt.publish("devices/sensor-01/status", "{\"online\":true}", true); // true = retain
// Non-retained: only delivered to currently-subscribed clients
mqtt.publish("devices/sensor-01/temperature", payload, false); // false = no retain
Use retained messages for: device online/offline status, configuration state, last known sensor values. Don’t use retained messages for: event streams, commands (you don’t want an old “turn on” command to execute when a device reconnects).
Subscribing and parsing incoming messages
// Callback signature — must match exactly
void onMQTTMessage(char* topic, byte* payload, unsigned int length) {
// payload is NOT null-terminated — you must handle the length explicitly
// WRONG:
// String msg = String((char*)payload); // May include garbage after the message
// CORRECT: copy to a null-terminated buffer
char msg[length + 1];
memcpy(msg, payload, length);
msg[length] = '\0';
Serial.printf("[MQTT] Topic: %s | Message: %s\n", topic, msg);
// Route by topic
if (strcmp(topic, "devices/sensor-01/commands") == 0) {
handleCommand(msg);
}
}
void handleCommand(const char* payload) {
// Parse simple JSON manually (or use ArduinoJson)
// Expected format: {"command":"relay","state":true}
StaticJsonDocument<256> doc;
DeserializationError err = deserializeJson(doc, payload);
if (err) {
Serial.printf("[CMD] JSON parse error: %s\n", err.c_str());
return;
}
const char* command = doc["command"];
if (command && strcmp(command, "relay") == 0) {
bool state = doc["state"] | false;
digitalWrite(RELAY_PIN, state ? HIGH : LOW);
Serial.printf("[CMD] Relay set to %s\n", state ? "ON" : "OFF");
}
}
Register the callback before connecting:
mqtt.setCallback(onMQTTMessage);
The robust reconnect loop
Here’s the pattern that handles both WiFi and MQTT drops:
void maintainConnections() {
if (!ensureWiFi()) {
// WiFi failed — nothing we can do right now, try again next cycle
return;
}
if (!mqtt.connected()) {
ensureMQTT(); // Attempt reconnect; if it fails, we'll try next cycle
}
}
Call maintainConnections() at the top of your main loop and call mqtt.loop() every iteration. Do NOT put delays that are longer than the keepalive interval without calling mqtt.loop() — the client will be considered disconnected by the broker.
Complete example: temperature publisher with relay control
#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <DHT.h>
// Configuration
const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASSWORD = "your-password";
const char* MQTT_HOST = "192.168.1.100";
const int MQTT_PORT = 1883;
const char* CLIENT_ID = "esp32-sensor-01";
const char* TOPIC_TELEMETRY = "devices/sensor-01/telemetry";
const char* TOPIC_COMMANDS = "devices/sensor-01/commands";
const char* TOPIC_STATUS = "devices/sensor-01/status";
// Hardware
#define DHT_PIN 21
#define DHT_TYPE DHT22
#define RELAY_PIN 22
DHT dht(DHT_PIN, DHT_TYPE);
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
// Timing
uint32_t lastPublishMs = 0;
const uint32_t PUBLISH_INTERVAL_MS = 5000;
// ---- WiFi ----
bool ensureWiFi() {
if (WiFi.status() == WL_CONNECTED) return true;
Serial.printf("[WiFi] Connecting to %s\n", WIFI_SSID);
WiFi.disconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED) {
if (millis() - start > 15000) {
Serial.println("[WiFi] Timeout");
return false;
}
vTaskDelay(pdMS_TO_TICKS(250));
}
Serial.printf("[WiFi] Connected, IP: %s\n", WiFi.localIP().toString().c_str());
return true;
}
// ---- MQTT callbacks ----
void handleCommand(const char* payload) {
StaticJsonDocument<256> doc;
if (deserializeJson(doc, payload) != DeserializationError::Ok) return;
const char* cmd = doc["command"] | "";
if (strcmp(cmd, "relay") == 0) {
bool on = doc["state"] | false;
digitalWrite(RELAY_PIN, on ? HIGH : LOW);
Serial.printf("[CMD] Relay → %s\n", on ? "ON" : "OFF");
}
}
void onMessage(char* topic, byte* payload, unsigned int len) {
char msg[len + 1];
memcpy(msg, payload, len);
msg[len] = '\0';
if (strcmp(topic, TOPIC_COMMANDS) == 0) {
handleCommand(msg);
}
}
// ---- MQTT connection ----
bool ensureMQTT() {
if (mqtt.connected()) return true;
bool ok = mqtt.connect(
CLIENT_ID,
nullptr, nullptr, // no auth for this example
TOPIC_STATUS, 1, true, // LWT
"{\"online\":false}"
);
if (!ok) {
Serial.printf("[MQTT] Connect failed, state=%d\n", mqtt.state());
return false;
}
Serial.println("[MQTT] Connected");
mqtt.publish(TOPIC_STATUS, "{\"online\":true}", true);
mqtt.subscribe(TOPIC_COMMANDS, 1);
return true;
}
// ---- Telemetry ----
void publishTelemetry() {
float temp = dht.readTemperature();
float humi = dht.readHumidity();
if (isnan(temp) || isnan(humi)) {
Serial.println("[Sensor] Read failed");
return;
}
StaticJsonDocument<256> doc;
doc["temperature"] = serialized(String(temp, 1));
doc["humidity"] = serialized(String(humi, 1));
doc["uptime_s"] = millis() / 1000;
char buf[256];
serializeJson(doc, buf, sizeof(buf));
if (!mqtt.publish(TOPIC_TELEMETRY, buf, false)) {
Serial.println("[MQTT] Publish failed — check buffer size or connection");
} else {
Serial.printf("[Telemetry] %s\n", buf);
}
}
// ---- Main ----
void setup() {
Serial.begin(115200);
pinMode(RELAY_PIN, OUTPUT);
digitalWrite(RELAY_PIN, LOW);
dht.begin();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setBufferSize(1024); // Don't forget this
mqtt.setKeepAlive(30); // Send PINGREQ every 30s to keep connection alive
}
void loop() {
// 1. Ensure connectivity
if (ensureWiFi() && !mqtt.connected()) {
ensureMQTT();
}
// 2. Drive the MQTT client state machine (handles ACKs, pings, incoming messages)
if (mqtt.connected()) {
mqtt.loop();
}
// 3. Publish telemetry on interval
if (mqtt.connected() && (millis() - lastPublishMs >= PUBLISH_INTERVAL_MS)) {
lastPublishMs = millis();
publishTelemetry();
}
vTaskDelay(pdMS_TO_TICKS(10));
}
What’s next
With reliable MQTT communication in place, the next problem is firmware architecture. A single loop() handling WiFi reconnect, MQTT reconnect, sensor reading, and OTA becomes unmaintainable fast. The ESP32 FreeRTOS post covers how to split this into independent tasks with proper inter-task communication via queues — so your MQTT reconnect logic doesn’t block your sensor reads.
Comments
Enjoyed this tutorial?
Get new ESP32, Arduino, and industrial IoT tutorials straight to your inbox — no spam, unsubscribe anytime.