The ESP8266 is still one of the cheapest ways to put a sensor on Wi-Fi, and PubSubClient is the MQTT library most ESP8266 sketches use. This tutorial gives you a complete sketch for a NodeMCU or Wemos D1 mini that connects to an MQTT broker, publishes telemetry, receives commands, reconnects without blocking and announces itself with a Last Will — then upgrades the connection to TLS with a certificate fingerprint and explains when to use a root CA instead.
Tested setup: Arduino IDE 2.x or arduino-cli, ESP8266 core 3.1.x, PubSubClient 2.8.
Install the toolchain
- In the Arduino IDE, open Preferences and add this board manager URL:
https://arduino.esp8266.com/stable/package_esp8266com_index.json. - Boards Manager → install esp8266 by ESP8266 Community.
- Library Manager → install PubSubClient by Nick O'Leary.
- Select NodeMCU 1.0 (ESP-12E Module) or LOLIN(WEMOS) D1 R2 & mini as the board.
With arduino-cli the same steps are:
arduino-cli config add board_manager.additional_urls \
https://arduino.esp8266.com/stable/package_esp8266com_index.json
arduino-cli core update-index
arduino-cli core install esp8266:esp8266
arduino-cli lib install PubSubClientThe ESP8266 only supports 2.4 GHz Wi-Fi. If your router broadcasts one combined SSID for both bands, that usually works; a 5 GHz-only network never will.
The complete sketch
#include <ESP8266WiFi.h>
#include <PubSubClient.h>
const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASS = "your-wifi-password";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* MQTT_USER = "username";
const char* MQTT_PASS = "password";
WiFiClient net;
PubSubClient mqtt(net);
String clientId, baseTopic;
unsigned long lastAttempt = 0, retryDelay = 1000, 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("[%s] %s\n", topic, msg.c_str());
if (msg == "on") digitalWrite(LED_BUILTIN, LOW); // built-in LED is active-low
if (msg == "off") digitalWrite(LED_BUILTIN, HIGH);
}
bool connectMqtt() {
String status = baseTopic + "/status";
// connect(id, user, pass, willTopic, willQos, willRetain, willMessage)
if (!mqtt.connect(clientId.c_str(), MQTT_USER, MQTT_PASS,
status.c_str(), 1, true, "offline")) {
Serial.printf("MQTT connect failed, state=%d\n", mqtt.state());
return false;
}
mqtt.publish(status.c_str(), "online", true); // retained
mqtt.subscribe((baseTopic + "/led").c_str(), 1);
Serial.println("MQTT connected as " + clientId);
return true;
}
void setup() {
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, HIGH);
WiFi.persistent(false); // don't rewrite flash on every boot
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
clientId = "esp8266-" + String(ESP.getChipId(), HEX);
baseTopic = "testmqtt/esp8266/" + clientId;
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setBufferSize(512); // default is 256 bytes incl. topic and header
mqtt.setKeepAlive(30);
mqtt.setSocketTimeout(5); // a failed connect blocks for at most ~5 s
}
void loop() {
if (WiFi.status() != WL_CONNECTED) {
delay(10); // core reconnects Wi-Fi on its own
return;
}
if (!mqtt.connected()) {
unsigned long now = millis();
if (now - lastAttempt >= retryDelay) {
lastAttempt = now;
retryDelay = connectMqtt() ? 1000 : min(retryDelay * 2, 60000UL);
}
return;
}
mqtt.loop(); // must run often: receives messages, sends pings
if (millis() - lastPublish >= 10000) {
lastPublish = millis();
char payload[96];
snprintf(payload, sizeof(payload),
"{\"rssi\":%d,\"heap\":%u,\"uptime\":%lu}",
WiFi.RSSI(), ESP.getFreeHeap(), millis() / 1000);
mqtt.publish((baseTopic + "/telemetry").c_str(), payload);
}
}Why it is written this way
- Non-blocking reconnect. The classic example uses
while (!client.connected()) { ...; delay(5000); }, which freezes everything else — buttons, sensors, even the Wi-Fi stack — while the broker is unreachable. Here,loop()returns immediately and retries with exponential backoff (1 s, 2 s, 4 s … up to 60 s), so a fleet does not hammer a broker that is restarting. - Stable client ID from
ESP.getChipId(). Two boards with the same ID kick each other off in an endless loop; see client ID best practices. - Last Will + retained status. If the board loses power, the broker publishes
offlineto.../statusafter the keep-alive expires (1.5 × 30 s here). Anything subscribing later sees the current state immediately. - Subscribe after every connect. PubSubClient uses a clean session, so subscriptions do not survive a reconnect. Shared test endpoints such as
broker.connectmq.comdo not keep sessions anyway, so the sketch always resubscribes inconnectMqtt(). setBufferSize(512)— PubSubClient's default buffer is 256 bytes for the whole packet, including the topic. A long topic plus a JSON payload exceeds that quickly, andpublish()then returnsfalsewithout any other hint. The packet size calculator shows how big your packet really is.setSocketTimeout(5)—connect()itself is blocking. The default timeout is 15 seconds, long enough to trigger watchdog resets or a frozen UI.
Test the sketch
Flash the board and open the Serial Monitor at 115200 baud. You should see MQTT connected as esp8266-xxxxxx. If you see state=-2 or another code instead, jump to the error table below.
Then open the online MQTT client, connect to broker.connectmq.com and subscribe to testmqtt/esp8266/#. The retained online status shows up first, then a telemetry JSON every 10 seconds. Publish on to testmqtt/esp8266/<client-id>/led and the LED lights up.
Watch your ESP8266 in the browser
To test the Last Will, unplug the board and wait about 45 seconds: the status topic flips to offline. Plug it back in and it returns to online — that is your reconnect path working.
Adding TLS with a fingerprint
Port 1883 sends the MQTT password in clear text. The ESP8266 core includes BearSSL, so TLS on port 8883 only needs a different client class. The lightest way to verify the server is certificate pinning by SHA-1 fingerprint.
Get the fingerprint of the broker's current certificate from your computer:
openssl s_client -connect broker.connectmq.com:8883 \
-servername broker.connectmq.com </dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha1 \
| sed 's/.*=//; s/:/ /g'Then change three things in the sketch:
#include <WiFiClientSecure.h>
const uint16_t MQTT_PORT = 8883;
const char* FINGERPRINT = "AA BB CC DD EE FF 00 11 22 33 44 55 66 77 88 99 AA BB CC DD";
BearSSL::WiFiClientSecure net; // replaces WiFiClient
PubSubClient mqtt(net);
// in setup(), before mqtt.setServer():
net.setFingerprint(FINGERPRINT);
mqtt.setSocketTimeout(10); // the TLS handshake is slow on an 80 MHz chipThe handshake takes noticeably longer than a plain TCP connect and needs a good chunk of the ESP8266's limited RAM. Watch ESP.getFreeHeap() in the telemetry; if it gets tight, BearSSL can use smaller buffers with net.setBufferSizes(1024, 1024), but only when the broker supports the TLS max fragment length extension — check first with BearSSL::WiFiClientSecure::probeMaxFragmentLength(MQTT_HOST, 8883, 1024).
Fingerprint vs root CA
| Method | How | Trade-off |
|---|---|---|
setFingerprint() | Pin the leaf certificate's SHA-1 | Smallest and fastest, but breaks on every certificate renewal |
setTrustAnchors() | Pin the root CA (BearSSL::X509List) | Survives renewals; needs the correct time via configTime() |
setInsecure() | No verification | Encrypted but accepts any server — debugging only |
Certificates from public CAs are renewed regularly, often every few months. A fingerprint-pinned fleet stops connecting on renewal day with state=-2, and you have to reflash every device. For anything long-lived, pin the root CA instead:
#include <time.h>
static const char ROOT_CA[] PROGMEM = R"EOF(
-----BEGIN CERTIFICATE-----
...root certificate of the CA that signs your broker's certificate...
-----END CERTIFICATE-----
)EOF";
BearSSL::X509List trustAnchor(ROOT_CA);
// in setup(), after Wi-Fi is connected:
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
while (time(nullptr) < 1700000000) delay(200); // wait for NTP
net.setTrustAnchors(&trustAnchor);Without a valid clock, BearSSL considers every certificate expired or not yet valid. More TLS detail, including client certificates, is in the ESP32 TLS guide — the BearSSL API differs, but the concepts are the same.
Error codes
mqtt.state() returns these values:
| state | Constant | Typical cause |
|---|---|---|
| -4 | MQTT_CONNECTION_TIMEOUT | No ping response within keep-alive; weak Wi-Fi |
| -3 | MQTT_CONNECTION_LOST | Network dropped or broker closed the socket |
| -2 | MQTT_CONNECT_FAILED | TCP/TLS failed: wrong host or port, DNS, firewall, stale fingerprint |
| -1 | MQTT_DISCONNECTED | Clean disconnect, or never connected |
| 1 | MQTT_CONNECT_BAD_PROTOCOL | Broker refuses MQTT 3.1.1 |
| 2 | MQTT_CONNECT_BAD_CLIENT_ID | Client ID rejected |
| 3 | MQTT_CONNECT_UNAVAILABLE | Broker unavailable |
| 4 | MQTT_CONNECT_BAD_CREDENTIALS | Wrong username or password |
| 5 | MQTT_CONNECT_UNAUTHORIZED | Authenticated but not allowed to connect |
Positive values are CONNACK return codes from the broker — look them up in the reason codes tool. Negative values come from the library itself and almost always mean a network problem. The step-by-step method in testing an ESP32 broker connection works the same on an ESP8266.
Next steps
- Moving to a newer chip? The ESP32 Arduino tutorial uses the same PubSubClient API.
- Need QoS 1 publishing or a different API style? Compare with the arduino-mqtt library.
- Building bigger payloads? ArduinoJson with MQTT shows how to size buffers correctly.
A reliable ESP8266 MQTT node comes down to a few habits: never block loop(), reconnect with backoff, resubscribe on every connect, size the buffer for your real packets, and pin a root CA rather than a fingerprint for devices that must keep working after the next certificate renewal.
Frequently asked questions
Why does my ESP8266 TLS connection stop working after a few weeks?
If you pinned the certificate with setFingerprint(), the fingerprint changes every time the broker renews its certificate, and the handshake then fails with state -2. Pin the root CA with setTrustAnchors() instead, which survives renewals as long as the same CA signs the new certificate.
Can PubSubClient publish with QoS 1 on the ESP8266?
No. PubSubClient publishes at QoS 0 only. It can subscribe with QoS 0 or 1, so incoming messages can be acknowledged, but outgoing messages are fire-and-forget. If you need QoS 1 publishes, use a library such as arduino-mqtt or add an application-level acknowledgement.
What does PubSubClient state -2 mean?
MQTT_CONNECT_FAILED: the TCP or TLS connection to the broker could not be opened. The problem is below MQTT — Wi-Fi, DNS, the wrong port, a firewall, or a TLS handshake failure such as a stale fingerprint or a plain WiFiClient on port 8883.


