The ESP32 is the go-to microcontroller for MQTT projects: built-in WiFi, plenty of RAM for TLS, and first-class Arduino support. This tutorial builds a complete sketch with the PubSubClient library that publishes sensor readings as JSON, listens on a command topic, reconnects on its own and — in the last step — switches to TLS.
What you need
- An ESP32 dev board and a USB cable.
- Arduino IDE 2.x (or PlatformIO) with the esp32 by Espressif Systems board package installed via the Boards Manager.
- The PubSubClient library by Nick O'Leary, installed via the Library Manager.
- A broker. We use the public HiveMQ broker on port
1883for testing — fine for a demo, but anyone can read the topics.
Topic layout
A clean topic structure makes devices easy to manage. Each board gets its own prefix based on a unique device ID:
| Topic | Direction | Purpose |
|---|---|---|
testmqtt/esp32/<id>/telemetry | ESP32 to broker | Sensor JSON every 10 seconds |
testmqtt/esp32/<id>/status | ESP32 to broker | online / offline (retained, Last Will) |
testmqtt/esp32/<id>/cmd | Broker to ESP32 | Commands such as led:on |
See MQTT topics and wildcards for naming conventions.
The complete sketch
#include <WiFi.h>
#include <PubSubClient.h>
const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASSWORD = "your-wifi-password";
const char* MQTT_HOST = "broker.hivemq.com";
const uint16_t MQTT_PORT = 1883;
const char* MQTT_USER = nullptr; // set for brokers that require auth
const char* MQTT_PASS = nullptr;
const int LED_PIN = 2; // on-board LED on many dev boards
WiFiClient net;
PubSubClient mqtt(net);
char deviceId[24];
char topicTelemetry[64];
char topicStatus[64];
char topicCmd[64];
unsigned long lastReconnectAttempt = 0;
unsigned long lastPublish = 0;
void onMessage(char* topic, byte* payload, unsigned int length) {
String msg;
for (unsigned int i = 0; i < length; i++) msg += (char)payload[i];
Serial.printf("Message on %s: %s\n", topic, msg.c_str());
if (msg == "led:on") digitalWrite(LED_PIN, HIGH);
if (msg == "led:off") digitalWrite(LED_PIN, LOW);
}
void connectWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nWiFi connected, IP %s\n", WiFi.localIP().toString().c_str());
}
bool connectMqtt() {
// Last Will: broker publishes "offline" (retained) if we drop unexpectedly
bool ok = mqtt.connect(deviceId, MQTT_USER, MQTT_PASS,
topicStatus, 1, true, "offline");
if (!ok) {
Serial.printf("MQTT connect failed, state=%d\n", mqtt.state());
return false;
}
Serial.println("MQTT connected");
mqtt.publish(topicStatus, "online", true);
mqtt.subscribe(topicCmd, 1);
return true;
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
// Unique client ID from the chip's MAC address
uint64_t mac = ESP.getEfuseMac();
snprintf(deviceId, sizeof(deviceId), "esp32-%04X%08X",
(uint16_t)(mac >> 32), (uint32_t)mac);
snprintf(topicTelemetry, sizeof(topicTelemetry), "testmqtt/esp32/%s/telemetry", deviceId);
snprintf(topicStatus, sizeof(topicStatus), "testmqtt/esp32/%s/status", deviceId);
snprintf(topicCmd, sizeof(topicCmd), "testmqtt/esp32/%s/cmd", deviceId);
Serial.printf("Device ID: %s\n", deviceId);
connectWiFi();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setBufferSize(512); // default is 256 bytes for the whole packet
mqtt.setKeepAlive(30);
}
void loop() {
if (WiFi.status() != WL_CONNECTED) {
connectWiFi();
}
if (!mqtt.connected()) {
// Non-blocking reconnect: try every 5 seconds without stalling loop()
unsigned long now = millis();
if (now - lastReconnectAttempt > 5000) {
lastReconnectAttempt = now;
if (connectMqtt()) lastReconnectAttempt = 0;
}
return;
}
mqtt.loop(); // must run often: handles keep-alive and incoming messages
unsigned long now = millis();
if (now - lastPublish > 10000) {
lastPublish = now;
float temperature = temperatureRead(); // ESP32 internal sensor (rough)
char payload[128];
snprintf(payload, sizeof(payload),
"{\"device\":\"%s\",\"temp\":%.1f,\"rssi\":%d,\"uptime\":%lu}",
deviceId, temperature, WiFi.RSSI(), now / 1000);
bool sent = mqtt.publish(topicTelemetry, payload);
Serial.printf("Publish %s: %s\n", sent ? "ok" : "FAILED", payload);
}
}Open the Serial Monitor at 115200 baud to see the device ID, then watch the telemetry arrive in the browser by subscribing to testmqtt/esp32/#. Publish led:on to the cmd topic and the LED lights up.
Watch your ESP32 telemetry live
How the sketch works
Unique client IDs
MQTT brokers allow only one connection per client ID. If two boards flash the same hard-coded ID such as ESP32Client, they disconnect each other in an endless loop. Deriving the ID from the eFuse MAC address keeps it unique and stable across reboots. The client ID generator explains the rules if you want a different scheme.
A non-blocking reconnect loop
Many examples use a while (!client.connected()) loop with delay(5000). That blocks everything else on the board. The millis() pattern above retries every five seconds while the rest of loop() keeps running. mqtt.state() tells you why a connect failed: -2 is a network failure, 4 bad credentials and 5 not authorized. Compare with the full list in our MQTT connection errors guide.
Buffer size and MQTT_MAX_PACKET_SIZE
PubSubClient's buffer must hold the entire packet: fixed header, topic, and payload. The default is 256 bytes, and a JSON payload with a long topic exceeds it quickly — publish() then silently returns false. Older tutorials tell you to edit MQTT_MAX_PACKET_SIZE in the library header; since version 2.8 just call setBufferSize() before connecting. The MQTT packet size calculator tells you how big your packets really are.
PubSubClient limitations
- MQTT 3.1.1 only — no MQTT 5 properties.
- Publishes with QoS 0 only; subscriptions can use QoS 0 or 1.
- Retained publishes and Last Will are supported, as shown above.
Online status with Last Will and retained messages
The connect() call registers a Last Will: if the board loses power or WiFi without a clean disconnect, the broker publishes offline to the status topic after the keep-alive timeout. Right after connecting, the sketch publishes online to the same topic. Both are retained, so a dashboard that subscribes later still sees the current state immediately. This pattern is explained in Last Will and Testament and retained messages.
Testing from the command line
You do not need a second device to test the command topic. With the Mosquitto clients installed, send a command and watch telemetry (replace the ID with the one printed on the Serial Monitor):
# watch everything your board publishes
mosquitto_sub -h broker.hivemq.com -t 'testmqtt/esp32/#' -v
# switch the LED on
mosquitto_pub -h broker.hivemq.com -t 'testmqtt/esp32/esp32-ABCD12345678/cmd' -m 'led:on'More command-line recipes are in mosquitto_pub and mosquitto_sub examples.
Building JSON with ArduinoJson
snprintf is fine for a handful of fields, but for nested data or parsing JSON commands, the ArduinoJson library (version 7) is safer:
#include <ArduinoJson.h>
void publishTelemetry() {
JsonDocument doc;
doc["device"] = deviceId;
doc["temp"] = temperatureRead();
doc["rssi"] = WiFi.RSSI();
char payload[256];
size_t n = serializeJson(doc, payload, sizeof(payload));
mqtt.publish(topicTelemetry, (const uint8_t*)payload, n, false);
}Keep payloads small and flat: short keys and numbers instead of strings save RAM, airtime and broker bandwidth.
Troubleshooting state codes
mqtt.state() | Meaning | Typical fix |
|---|---|---|
-4 | Connection timeout | Check the keep-alive and that loop() runs often |
-2 | Network connect failed | Wrong host or port, firewall, or TLS handshake failure |
2 | Client ID rejected | Use a shorter or unique client ID |
4 | Bad username or password | Check credentials, including case |
5 | Not authorized | The broker's access rules deny this client |
Adding TLS with WiFiClientSecure
Plain port 1883 sends your credentials and data in clear text. Switching to TLS on port 8883 takes three changes: include WiFiClientSecure.h, swap the network client, and give it the CA certificate of your broker:
#include <WiFiClientSecure.h>
// PEM of the root CA that signed your broker's certificate
static const char ROOT_CA[] PROGMEM = R"EOF(
-----BEGIN CERTIFICATE-----
...paste the root CA certificate here...
-----END CERTIFICATE-----
)EOF";
WiFiClientSecure net;
PubSubClient mqtt(net);
const uint16_t MQTT_PORT = 8883;
// in setup(), before mqtt.setServer():
net.setCACert(ROOT_CA);
// net.setInsecure(); // skips verification - local testing only!Many public brokers use certificates from Let's Encrypt, whose root is ISRG Root X1; check your broker's documentation for the right one. TLS also needs the correct time for certificate validity checks on some setups, and it uses noticeably more RAM — an ESP32 handles it fine, an ESP8266 struggles. For the full picture, read our MQTT security best practices.
Want this sketch with your broker's host, port and credentials already filled in? The MQTT code generator generates it for your own broker settings.
Frequently asked questions
Why does PubSubClient not publish messages larger than 256 bytes?
PubSubClient uses a 256-byte buffer by default, and that buffer must hold the whole MQTT packet including the topic. publish() returns false when the packet does not fit. Call client.setBufferSize(1024) or larger before connecting.
Why does my ESP32 keep disconnecting from the MQTT broker?
The most common causes are a client ID that another device also uses, long delay() calls that stop client.loop() from running so keep-alive pings are missed, and weak WiFi. Use a unique client ID per device and keep loop() non-blocking.
Does PubSubClient support QoS 1 and MQTT 5?
PubSubClient speaks MQTT 3.1.1 only. It can subscribe with QoS 0 or 1 but publishes with QoS 0 only. If you need QoS 1 publishing or MQTT 5 features, look at libraries such as AsyncMqttClient or the ESP-IDF esp-mqtt component.