PubSubClient is the most widely used MQTT library for Arduino-compatible boards, and it is small enough to run on almost anything with a network stack. In this tutorial you will install it, connect an ESP32 to a real broker, publish and subscribe, and learn the three settings that cause most field problems: buffer size, keep alive and socket timeout. By the end you will have a reconnect-safe sketch you can reuse in every project.
What PubSubClient Is (and What It Is Not)
PubSubClient, written by Nick O'Leary, is a lightweight MQTT 3.1.1 client. It does not own the network connection. Instead, it wraps any object that implements Arduino's Client interface: WiFiClient on the ESP32 and ESP8266, EthernetClient on a wired shield, or WiFiClientSecure when you want TLS. That design is why the same library runs on so many boards.
It is also deliberately minimal. Knowing the limits up front saves hours of debugging:
| Feature | PubSubClient behaviour |
|---|---|
| Protocol version | MQTT 3.1.1 (MQTT 3.1 optional at compile time), no MQTT 5 |
| Publish QoS | QoS 0 only |
| Subscribe QoS | QoS 0 or QoS 1 |
| Default packet buffer | 256 bytes (topic + payload + header) |
| Default keep alive | 15 seconds |
| Default socket timeout | 15 seconds |
| Last Will | Supported on connect() |
| Retained publish | Supported |
| Threading model | Single-threaded, you must call loop() often |
If you need MQTT 5 properties or QoS 2 publishing from the device, look at a different client. For the vast majority of sensors and actuators, QoS 0 telemetry plus QoS 1 command subscriptions are exactly what you want. Our guide to MQTT QoS levels explains why that combination works well.
Install PubSubClient and Prepare the Board
- In the Arduino IDE open Tools → Manage Libraries, search for "PubSubClient" and install the library by Nick O'Leary (version 2.8 or newer).
- Install the board package you need: "esp32 by Espressif Systems" for ESP32 boards or "esp8266 by ESP8266 Community" for NodeMCU-style boards.
- Select your board and port, then upload the empty Blink sketch once to confirm the toolchain works.
- Pick a unique device ID, for example
pubsub-7f3a9c. You will use it as both the MQTT client ID and the topic prefix.
For the first test you will use the public area of broker.connectmq.com, the free public MQTT broker run by ConnectMQ. It accepts anonymous connections on port 1883 as long as the client ID is not empty. Everything published there is readable by anyone, so never send real credentials or personal data to it.
The Complete PubSubClient Example
The sketch below connects to Wi-Fi, connects to the broker with a Last Will, publishes a JSON message every five seconds, subscribes to a command topic and reconnects without blocking the rest of your code.
#include <WiFi.h> // use <ESP8266WiFi.h> on ESP8266
#include <PubSubClient.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* DEVICE_ID = "pubsub-7f3a9c"; // make this unique
char topicTelemetry[64];
char topicCommand[64];
char topicStatus[64];
WiFiClient netClient;
PubSubClient mqtt(netClient);
unsigned long lastPublish = 0;
unsigned long lastReconnectAttempt = 0;
void onMessage(char* topic, byte* payload, unsigned int length) {
// payload is NOT null-terminated: copy it with the length
char msg[64];
unsigned int n = length < sizeof(msg) - 1 ? length : sizeof(msg) - 1;
memcpy(msg, payload, n);
msg[n] = '\0';
Serial.printf("Message on %s: %s\n", topic, msg);
if (strcmp(msg, "ping") == 0) {
mqtt.publish(topicStatus, "pong");
}
}
void connectWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nIP address: %s\n", WiFi.localIP().toString().c_str());
}
bool connectMqtt() {
// connect(clientId, user, pass, willTopic, willQos, willRetain, willMessage)
bool ok = mqtt.connect(DEVICE_ID, nullptr, nullptr,
topicStatus, 1, true, "offline");
if (ok) {
mqtt.publish(topicStatus, "online", true);
mqtt.subscribe(topicCommand, 1);
Serial.println("MQTT connected");
} else {
Serial.printf("MQTT connect failed, rc=%d\n", mqtt.state());
}
return ok;
}
void setup() {
Serial.begin(115200);
snprintf(topicTelemetry, sizeof(topicTelemetry), "connectmq/demo/%s/telemetry", DEVICE_ID);
snprintf(topicCommand, sizeof(topicCommand), "connectmq/demo/%s/cmd", DEVICE_ID);
snprintf(topicStatus, sizeof(topicStatus), "connectmq/demo/%s/status", DEVICE_ID);
connectWifi();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setBufferSize(512); // room for larger JSON payloads
mqtt.setKeepAlive(30); // seconds
mqtt.setSocketTimeout(10); // seconds
}
void loop() {
if (WiFi.status() != WL_CONNECTED) {
connectWifi();
}
if (!mqtt.connected()) {
unsigned long now = millis();
if (now - lastReconnectAttempt > 5000) {
lastReconnectAttempt = now;
connectMqtt();
}
return;
}
mqtt.loop();
if (millis() - lastPublish > 5000) {
lastPublish = millis();
char payload[96];
snprintf(payload, sizeof(payload),
"{\"uptime\":%lu,\"rssi\":%d,\"heap\":%u}",
millis() / 1000, WiFi.RSSI(), ESP.getFreeHeap());
if (!mqtt.publish(topicTelemetry, payload)) {
Serial.println("Publish failed");
}
}
}
Watch the output from your computer with the Mosquitto command-line tools:
mosquitto_sub -h broker.connectmq.com -p 1883 -i watcher-7f3a9c -t "connectmq/demo/pubsub-7f3a9c/#" -v
Then send a command to the board:
mosquitto_pub -h broker.connectmq.com -p 1883 -i sender-7f3a9c -t "connectmq/demo/pubsub-7f3a9c/cmd" -m "ping"
You should see pong appear on the status topic within a second.
How PubSubClient Callbacks Work
The callback signature is fixed: void callback(char* topic, byte* payload, unsigned int length). Three details matter.
First, the payload is a pointer into the library's internal buffer and it is not null-terminated. Treating it as a C string with Serial.println((char*)payload) prints garbage after the real message. Always copy using length, as the example does.
Second, the callback runs inside mqtt.loop(). If you publish from inside the callback, the library reuses the same buffer, so the original topic and payload pointers are no longer valid afterwards. Copy anything you need before you call publish().
Third, keep callbacks short. A delay(2000) inside the callback stops loop() from servicing the connection and can trigger a keep alive timeout. Set a flag or store the value, then act on it in your main loop.
Buffer Size: The Most Common PubSubClient Trap
Since version 2.8 you can call setBufferSize() at runtime instead of editing MQTT_MAX_PACKET_SIZE in the library header. The buffer must hold the whole MQTT packet: fixed header, topic length, topic, and payload. With the 256-byte default, a 220-character JSON document on a 40-character topic simply fails, and publish() returns false without any further explanation. Incoming messages that are too large are silently discarded before your callback is ever called.
Rules of thumb:
- Size the buffer for your largest topic plus payload, plus about 10 bytes of header.
- Call
setBufferSize()beforeconnect(), and check its return value: it returnsfalseif the allocation failed. - On the ESP8266 keep the buffer modest, because RAM is shared with TLS and Wi-Fi.
We go into detail, including streaming large payloads with beginPublish(), in PubSubClient buffer size: fixing large message failures.
Keep Alive and Socket Timeout
Keep alive tells the broker how long the client may stay silent. PubSubClient sends a PINGREQ when nothing else has been sent during the interval, but only if loop() is being called. If your code blocks for longer than roughly one and a half times the keep alive interval, the broker drops the session and your Last Will fires.
setKeepAlive(30)is a sensible default for mains-powered devices on decent Wi-Fi.- Go higher (60 to 120 seconds) on cellular links where every packet costs energy or data.
setSocketTimeout(10)limits how longconnect()and reads may block. Lower values keep a flaky network from freezing your device for 15 seconds at a time.
Reading Connection State
When connect() fails, mqtt.state() tells you why. Negative values are network-level problems; positive values are refusals from the broker.
state() |
Meaning |
|---|---|
| -4 | Connection timeout, no CONNACK in time |
| -3 | Connection lost |
| -2 | Network connect failed (DNS, port, TLS, routing) |
| -1 | Disconnected cleanly |
| 0 | Connected |
| 1 | Bad protocol version |
| 2 | Client ID rejected |
| 3 | Server unavailable |
| 4 | Bad username or password |
| 5 | Not authorised |
The most common one by far is -2. Our dedicated guide on PubSubClient rc=-2 and other state codes walks through every value with fixes.
Moving to TLS and Real Credentials
The public broker is perfect for learning, but production devices need encryption and their own credentials. With a managed MQTT broker from ConnectMQ you create a broker in the portal and receive a generated username, password and client ID. The change to the sketch is small:
#include <WiFiClientSecure.h>
WiFiClientSecure netClient; // replaces WiFiClient
PubSubClient mqtt(netClient);
// ISRG Root X1, the Let's Encrypt root used by broker.connectmq.com
const char* ROOT_CA = R"EOF(
-----BEGIN CERTIFICATE-----
...paste ISRG Root X1 PEM here...
-----END CERTIFICATE-----
)EOF";
void setup() {
// ... Wi-Fi as before ...
netClient.setCACert(ROOT_CA);
mqtt.setServer("broker.connectmq.com", 8883);
}
bool connectMqtt() {
return mqtt.connect("YOUR_CLIENT_ID", "YOUR_USERNAME", "YOUR_PASSWORD",
topicStatus, 1, true, "offline");
}
ConnectMQ binds each client ID to the credentials it issued, so use the exact client ID from the portal. Topic prefixes on a private broker are yours to design; the public connectmq/demo/... prefix is no longer needed. For more hardening ideas, see MQTT security best practices.
Troubleshooting Checklist
- Connects, then drops every few seconds: two devices share a client ID. The broker disconnects the older session whenever the second one connects.
publish()returns false: payload plus topic exceeds the buffer, or the client is not connected.- Callback never fires: you forgot
mqtt.loop(), the subscription was made before the connection existed, or the message is larger than the buffer. - Random disconnects under load: long blocking code in
loop(); break it up or move it to a timer. - Works on 1883, fails on 8883: wrong root certificate, or the clock is not set on boards that check validity dates (common on ESP8266).
If you are choosing between libraries, the ArduinoMqttClient library guide and the async MQTT client for ESP32 cover the main alternatives, and the ESP32 IoT tutorial puts all the pieces together into a complete sensor project.
Connect Your PubSubClient Devices to ConnectMQ
Once your sketch runs against the public broker, moving it to a private cloud MQTT broker is a two-line change. ConnectMQ gives you MQTT over TCP, TLS on 8883 and WebSockets, a browser MQTT client for testing, a live topic explorer and threshold alert rules. Create a free Serverless broker at app.connectmq.com, no credit card required, and you will have credentials in a couple of minutes.
Frequently Asked Questions
Does PubSubClient support MQTT 5?
No. PubSubClient speaks MQTT 3.1.1, which every mainstream broker, including ConnectMQ, still supports alongside MQTT 5. If you need MQTT 5 properties on the device, choose a different client library.
Can PubSubClient publish with QoS 1?
No. publish() always sends QoS 0. You can subscribe with QoS 1, so commands sent to the device with QoS 1 are delivered reliably by the broker.
Why does my PubSubClient payload print strange characters?
The payload buffer is not null-terminated. Copy length bytes into your own buffer and add a terminating \0 before treating it as a string.
What is the maximum message size in PubSubClient?
The buffer size is a 16-bit value, so the practical ceiling is just under 64 KB, but RAM is the real limit. Keep messages small on constrained boards and remember that your broker plan also has a maximum message size.



