All posts
esp32iotmqtt

ESP32 MQTT tutorial: publish and subscribe with PubSubClient

· 15 min read NEW

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 RESTMQTT
Connection modelNew TCP connection per requestPersistent TCP connection
Overhead per message~500 bytes (headers)~2 bytes minimum
Server pushPolling or long-pollingNative (subscribe model)
QoSNone (fire-and-forget)0, 1, or 2
Offline bufferingNoYes (broker queues retained/persistent)
Power consumptionHigh (TCP handshake each time)Low (persistent connection)
BidirectionalRequest-response onlyFull 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:

  1. The broker handles all the complexity of routing to multiple subscribers
  2. Devices can subscribe to receive commands without polling
  3. 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:

BrokerBest forNotes
Mosquitto (self-hosted)Full control, no SaaS dependencyYou manage auth, TLS, backups
EMQX (self-hosted)High scale, enterprise featuresMore complex to configure
HiveMQ Cloud (free tier)Prototyping, up to 100 connections10GB/month data limit
AWS IoT CoreAWS-integrated productionPer-message pricing, IAM auth
CloudMQTTSmall productionSimple 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():

CodeConstantMeaning
-4MQTT_CONNECTION_TIMEOUTServer didn’t respond in time
-3MQTT_CONNECTION_LOSTNetwork connection dropped
-2MQTT_CONNECT_FAILEDNetwork connection failed
-1MQTT_DISCONNECTEDClient is disconnected
0MQTT_CONNECTEDConnected
1MQTT_CONNECT_BAD_PROTOCOLWrong protocol version
2MQTT_CONNECT_BAD_CLIENT_IDClient ID rejected
3MQTT_CONNECT_UNAVAILABLEServer unavailable
4MQTT_CONNECT_BAD_CREDENTIALSWrong username/password
5MQTT_CONNECT_UNAUTHORIZEDClient not authorized

Log these in production — they tell you exactly what’s failing.

QoS levels explained

QoSDelivery guaranteeHow it worksUse case
0At most once (fire and forget)No acknowledgmentHigh-frequency telemetry where loss is acceptable
1At least onceBroker ACKs; sender retries until ACKedImportant data that must arrive (may duplicate)
2Exactly onceFour-way handshakeCritical 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.

Related posts

esp32iotmodbus
Jun 19, 2026
esp32architecture
Jun 19, 2026
esp32freertos
Jun 19, 2026

Comments

Enjoyed this tutorial?

Get new ESP32, Arduino, and industrial IoT tutorials straight to your inbox — no spam, unsubscribe anytime.