MQTT Protocol

Technical reference for MQTT — topic hierarchy, QoS levels, retained messages, LWT, and ESP32 implementation details

MQTT (originally MQ Telemetry Transport, sometimes expanded as Message Queuing Telemetry Transport) is a lightweight publish-subscribe messaging protocol built on top of TCP/IP. It was designed in 1999 by Andy Stanford-Clark (IBM) and Arlen Nipper for monitoring oil pipelines over unreliable satellite links -- exactly the kind of constrained environment that makes it ideal for ESP32 projects today. For a beginner-friendly walkthrough with a complete working example, see Introduction to MQTT. This page is a more technical reference.

🔗Protocol Versions

VersionYearStatusNotes
3.12010LegacyRarely used today
3.1.12014Most common in IoTOASIS standard, supported by all brokers and libraries
5.02019NewerAdds shared subscriptions, reason codes, topic aliases, message expiry

PubSubClient (the standard ESP32 library) supports MQTT 3.1.1. If you need MQTT 5.0 features, look at the arduino-mqtt library by Joel Gaehwiler or esp-mqtt (ESP-IDF).

🔗How It Works

MQTT uses a broker to mediate all communication. Devices never talk directly to each other -- they publish messages to the broker, and the broker forwards those messages to any device that has subscribed to the matching topic.

graph LR
    E1[ESP32 — Temp Sensor] -->|publish<br>home/temp| B[MQTT Broker]
    E2[ESP32 — Door Sensor] -->|publish<br>home/door| B
    B -->|subscribe<br>home/#| D[Dashboard]
    B -->|subscribe<br>home/door| P[Phone Alert]
    B -->|subscribe<br>home/temp| E3[ESP32 — Thermostat]

The broker handles authentication, access control, message routing, and persistence. The ESP32 only needs to maintain a single TCP connection to the broker.

🔗Topic Hierarchy

Topics are UTF-8 strings organized in a hierarchy separated by /. They are case-sensitive.

home/livingroom/temperature
home/livingroom/humidity
home/garden/soil-moisture
devices/esp32-kitchen/status

Go from general to specific (home/livingroom/temperature, not temperature/home/livingroom). Use lowercase and hyphens. Avoid leading / (the topic /home/temp has an empty first level). Topics are created on first use -- no pre-registration needed.

🔗Wildcards (Subscribe Only)

Wildcards can only be used when subscribing, never when publishing.

WildcardNameMatchesExample
+Single levelExactly one levelhome/+/temperature matches home/livingroom/temperature and home/kitchen/temperature, but not home/floor2/kitchen/temperature
#Multi levelZero or more levels; must be last characterhome/# matches home/livingroom/temperature, home/door, and even home itself

You can combine them: home/+/sensors/# matches home/livingroom/sensors/temp and home/kitchen/sensors/humidity/raw.

Warning: Subscribing to # (alone) matches every message on the entire broker. On a public broker this can flood your ESP32. On a private broker it is useful for debugging but should not be used in production.

🔗Quality of Service (QoS)

QoS is set per message (on publish) and per subscription. The effective QoS is the lower of the two.

QoSNameDelivery guaranteeBroker stores message?Extra packetsBest for
0At most onceNone -- fire and forgetNo0Frequent sensor readings where occasional loss is acceptable
1At least onceDelivered, but may be duplicatedUntil ACK received1 (PUBACK)Important data; receiver handles duplicates
2Exactly onceDelivered exactly onceUntil handshake completes3 (PUBREC, PUBREL, PUBCOMP)Payment or safety-critical commands (rare in IoT)

On ESP32: QoS 0 and 1 are the practical choices. QoS 2 requires four packets per message and PubSubClient does not support it (it will downgrade to QoS 1). For periodic sensor readings published every few seconds, QoS 0 is fine -- if one reading is lost, the next arrives moments later.

🔗Retained Messages

When you publish with the retain flag set to true, the broker stores that message. Any new subscriber to that topic immediately receives the last retained message, even if it was published hours ago.

// Publish with retain = true (last parameter)
mqtt.publish("home/thermostat/setpoint", "22.0", true);

This is useful for state information (device online/offline, current setpoint) where a new subscriber needs to know the current value without waiting for the next update.

To clear a retained message, publish an empty payload with retain set to true:

mqtt.publish("home/thermostat/setpoint", "", true);

🔗Last Will and Testament (LWT)

The LWT is a message that the broker publishes on your behalf if your client disconnects unexpectedly (network failure, crash, timeout). You configure it when connecting:

// connect(clientID, username, password, willTopic, willQoS, willRetain, willMessage)
mqtt.connect("esp32-kitchen", NULL, NULL,
             "devices/esp32-kitchen/status", 1, true, "offline");

When the ESP32 connects successfully, you typically publish "online" to the same topic with retain. If the ESP32 crashes, the broker publishes "offline" for you. Dashboards can subscribe to devices/+/status to monitor which devices are alive.

🔗Keep-Alive

The keep-alive interval tells the broker how long to wait before considering the client dead. The client must send at least one packet (a PINGREQ if no data) within each interval.

ParameterDefault (PubSubClient)Typical range
Keep-alive$15\,\text{s}$$15$--$300\,\text{s}$

The broker disconnects the client (and publishes the LWT) if no packet arrives within $1.5 \times$ the keep-alive interval.

mqtt.setKeepAlive(60);  // Set to 60 seconds

Important: You must call mqtt.loop() frequently in your main loop. This function sends PINGREQ packets and processes incoming messages. If loop() is not called within the keep-alive window, the broker will disconnect you.

🔗Clean Session

PubSubClient defaults to cleanSession = true, meaning the broker forgets all subscriptions when you disconnect. You must re-subscribe after every reconnect (which is why the example code subscribes inside reconnect()). Setting it to false makes the broker remember your subscriptions and queue QoS 1/2 messages while you are offline, but this consumes broker memory and queued messages arrive in a burst on reconnect.

🔗Network Details

ParameterValue
TransportTCP/IP
Default port$1883$ (unencrypted)
TLS port$8883$ (encrypted)
WebSocket port$8083$ or $8084$ (varies by broker)
Minimum packet size$2\,\text{bytes}$ (PINGREQ)
Maximum packet size$256\,\text{MB}$ (protocol limit)

🔗ESP32 Library: PubSubClient

PubSubClient by Nick O'Leary is the standard MQTT library for Arduino and ESP32.

SettingDefaultNotes
Max packet size$256\,\text{bytes}$Includes topic + payload. Increase if needed.
Keep-alive$15\,\text{s}$
Socket timeout$15\,\text{s}$
MQTT version3.1.1

Increasing the packet size (needed for longer JSON payloads):

// Must be called BEFORE mqtt.connect()
mqtt.setBufferSize(512);  // Increase to 512 bytes

If your publish silently fails (returns false), the payload likely exceeds the buffer size.

🔗Code Example: Connect, Publish JSON, Subscribe

#include <WiFi.h>
#include <PubSubClient.h>

const char* ssid          = "YourNetwork";
const char* password      = "YourPassword";
const char* mqtt_server   = "test.mosquitto.org";
const char* client_id     = "esp32-ref-demo";

WiFiClient net;
PubSubClient mqtt(net);

void callback(char* topic, byte* payload, unsigned int length) {
  char msg[length + 1];
  memcpy(msg, payload, length);
  msg[length] = '\0';
  Serial.printf("[%s] %s\n", topic, msg);
}

void reconnect() {
  while (!mqtt.connected()) {
    // Set LWT before connecting
    if (mqtt.connect(client_id, NULL, NULL,
                     "devices/esp32-ref-demo/status", 1, true, "offline")) {
      mqtt.publish("devices/esp32-ref-demo/status", "online", true);
      mqtt.subscribe("home/commands/#", 1);
    } else {
      delay(5000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  mqtt.setServer(mqtt_server, 1883);
  mqtt.setBufferSize(512);
  mqtt.setCallback(callback);
}

void loop() {
  if (!mqtt.connected()) reconnect();
  mqtt.loop();

  static unsigned long last = 0;
  if (millis() - last > 10000) {
    last = millis();

    char json[128];
    snprintf(json, sizeof(json),
             "{\"temp\":%.1f,\"hum\":%.1f,\"uptime\":%lu}",
             23.5, 61.2, millis() / 1000);

    mqtt.publish("home/livingroom/sensors", json);
  }
}

This sketch connects to WiFi and the MQTT broker, sets up an LWT, subscribes to a command topic with QoS 1, and publishes a JSON payload every $10\,\text{s}$.

🔗Common Brokers

BrokerTypeAddressNotes
test.mosquitto.orgPublic (testing)test.mosquitto.org:1883No auth, no privacy
HiveMQPublic (testing)broker.hivemq.com:1883Web client for debugging
EMQXPublic (testing)broker.emqx.io:1883Large-scale public broker
Mosquitto (self-hosted)PrivateYour server's IPLightweight, runs on Raspberry Pi
EMQX (self-hosted)PrivateYour server's IPDashboard, clustering, MQTT 5.0

Public brokers are for testing only. For real deployments, run your own broker with authentication and TLS (port $8883$).

🔗Common Issues

ProblemCauseFix
publish() returns falsePayload + topic exceeds bufferCall mqtt.setBufferSize(512) or larger
Frequent disconnectsmqtt.loop() not called often enoughAvoid delay() in main loop; call loop() every iteration
Two devices fightingSame client ID on the brokerEach device must have a unique client ID
Messages lost (QoS 0)Normal -- QoS 0 has no delivery guaranteeUse QoS 1 for important messages
Subscriptions lost after reconnectcleanSession = true (default)Re-subscribe in your reconnect function
Cannot connect to brokerFirewall blocks port $1883$Check firewall rules; try WebSocket port
LWT not publishedClient disconnected cleanly (not unexpected)LWT only fires on ungraceful disconnect

🔗Used In

The following pages on this site use MQTT: