
MQTT automatic reconnection without blocking the loop
The while loop found in every tutorial freezes the firmware when the broker goes down. Learn the millis() guard pattern, separate schedules and rc codes.
Hardware requirements:
- Placa ESP32 ou ESP32-S3
- Rede Wi-Fi de 2,4 GHz
- Um broker MQTT: Mosquitto local, HiveMQ ou equivalente
Download this tutorial's materials:
- Projeto completo (ZIP)138 KBFirmware Arduino com o padrão de guarda, cadências separadas de Wi-Fi e MQTT, client id derivado do MAC, Last Will e tradução dos códigos rc. README e manual em PDF.
- Manual em PDF (8 páginas)167 KBPor que o laço trava, o padrão correto, tabela completa dos códigos rc, o roteiro de teste derrubando broker e Wi-Fi, e checklist de treze itens.
Want to receive new manuals and industrial schematics? Join our channel on Telegram / WhatsApp
The loop every tutorial teaches, and you should not use
Search for MQTT reconnection on the ESP32 and you will find some variation of this:
| 1 | while (!client.connected()) { |
| 2 | Serial.print("Conectando ao broker..."); |
| 3 | if (client.connect("esp32")) { |
| 4 | Serial.println("conectado"); |
| 5 | } else { |
| 6 | Serial.print("falhou, rc="); |
| 7 | Serial.println(client.state()); |
| 8 | delay(5000); |
| 9 | } |
| 10 | } |
It works on the bench, with the broker on the same computer. In the field, it is a trap.
While this loop runs, the entire firmware is stopped. The web server does not respond, the physical button is not read, Modbus is not polled and the process state machine does not advance. If the broker is down for ten minutes, your device is dead for ten minutes — not disconnected, dead, doing nothing except trying and sleeping for five seconds.
Worse, in many projects this feeds the watchdog irregularly and the ESP32 restarts by itself. The diagnosis then becomes "the device is rebooting", and nobody connects the reset to the broker outage.
The inversion: a guard instead of a loop
The correct pattern has no loop at all. Call the connection function on every pass through loop(), and let it decide whether it needs to do anything:
| 1 | void connectMqtt() { |
| 2 | if (config.mqttHost.isEmpty() || mqtt.connected() || WiFi.status() != WL_CONNECTED) { |
| 3 | return; |
| 4 | } |
| 5 | if (millis() - lastMqttAttemptMs < 5000) { |
| 6 | return; |
| 7 | } |
| 8 | lastMqttAttemptMs = millis(); |
| 9 | mqtt.setServer(config.mqttHost.c_str(), config.mqttPort); |
| 10 | mqtt.setCallback(onMqttMessage); |
| 11 | if (mqtt.connect(config.deviceId.c_str(), config.deviceId.c_str(), config.deviceToken.c_str())) { |
| 12 | mqtt.subscribe(topicFor("commands").c_str()); |
| 13 | } else { |
| 14 | Serial.printf("Falha MQTT rc=%d\\n", mqtt.state()); |
| 15 | } |
| 16 | } |
Read the two guards carefully, because they contain the entire difference.
The first gives up in three cases: no broker configured, already connected or Wi-Fi down. Trying MQTT without a network is guaranteed waste — the call blocks until the socket timeout expires for no reason.
The second is the heart of the pattern. If the last attempt was less than five seconds ago, it returns immediately. This replaces delay(5000) without stopping the rest of the firmware: instead of sleeping for five seconds, the function returns in microseconds and loop() continues doing everything else. Once five seconds have passed, the next pass finds the guard open and tries again.
The effect is the same — one attempt every five seconds — but the cost is zero.
Wi-Fi and MQTT have different cadences on purpose
Notice that Wi-Fi reconnection uses the same structure with a much longer interval:
| 1 | void ensureWiFi() { |
| 2 | if (config.networkMode == "ap") { |
| 3 | return; |
| 4 | } |
| 5 | if (WiFi.status() == WL_CONNECTED) { |
| 6 | return; |
| 7 | } |
| 8 | if (millis() - lastWifiAttemptMs < 30000) { |
| 9 | return; |
| 10 | } |
| 11 | lastWifiAttemptMs = millis(); |
| 12 | connectWiFiOnce(5000); |
| 13 | } |
Thirty seconds for Wi-Fi, five for MQTT. This is not arbitrary.
Reassociating with Wi-Fi turns on the radio, scans, negotiates authentication and waits for DHCP. It costs energy and takes time. Trying every five seconds while the router is off wastes battery and does not speed anything up.
An MQTT connection is a TCP handshake over a network that is already up. It is cheap. And when the broker returns, you want to reconnect quickly because every disconnected second is telemetry that does not get published.
The general rule is: the more expensive the operation, the longer the interval between attempts.
The order inside loop matters
| 1 | void loop() { |
| 2 | handleSerialCommands(); |
| 3 | webServer.handleClient(); |
| 4 | updateConfigAccessPoint(); |
| 5 | ensureWiFi(); |
| 6 | connectMqtt(); |
| 7 | if (mqtt.connected()) { |
| 8 | mqtt.loop(); |
| 9 | } |
| 10 | runProgramTick(); |
| 11 | unsigned long now = millis(); |
| 12 | if (now - lastTelemetryMs >= 10000) { |
| 13 | lastTelemetryMs = now; |
| 14 | sendTelemetry(); |
| 15 | } |
| 16 | } |
Wi-Fi comes before MQTT because the second depends on the first. And mqtt.loop() is called only when there is a connection, protected by the if.
This mqtt.loop() is mandatory and frequently forgotten. It processes incoming messages and, most importantly, answers the broker's keepalive. Without calling it regularly, the broker considers the client dead and drops the connection — creating a reconnection cycle that looks like a network problem but is a code problem.
Also notice that there is no delay anywhere in the loop. All scheduling is done by comparing millis().
The rc codes and what each one means
When the connection fails, mqtt.state() returns a number. It is the most useful diagnostic information, and almost every tutorial prints it without explaining it:
| 1 | -4 timeout: broker unreachable, wrong IP or port |
| 2 | -3 connection lost: network dropped in the middle |
| 3 | -2 connection failed: DNS did not resolve or TCP was refused |
| 4 | -1 disconnected by a local call |
| 5 | 0 connected |
| 6 | 1 protocol version rejected by the broker |
| 7 | 2 client id rejected |
| 8 | 3 broker unavailable |
| 9 | 4 wrong username or password |
| 10 | 5 not authorized |
The practical reading is: negative values mean a network or address problem; positive values mean broker rejection, meaning the broker received your connection and said no.
-2 is usually a wrong hostname. 4 and 5 are credentials and, with token-authenticated brokers, almost always an expired token. 2 appears when the client id contains a character the broker does not accept or is too long.
Duplicate client ids and the false network diagnosis
This deserves its own section because it produces a misleading symptom.
The MQTT protocol requires every client to have a unique identifier. If two devices connect with the same id, the broker drops the first to accept the second. The first reconnects and drops the second, and so on forever.
The result is a pair of devices that repeatedly connects and drops, with no log error beyond disconnections. It is easy to confuse with unstable Wi-Fi.
The firmware above prevents it: the client id is deviceId, which is unique per device. If you use a literal such as "esp32" — the value found in almost every example — the problem appears the day the second board goes into operation.
If there is no natural identifier, derive one from the MAC address, which is unique at the factory.
Last Will: announce that it died
There is one case reconnection cannot solve: the device loses power. It cannot notify anyone, and the system on the other side keeps showing the last received value as if it were current.
MQTT solves this with the Last Will and Testament. At connection time, register a message that the broker publishes for you if the connection drops without a goodbye:
| 1 | mqtt.connect(clientId, usuario, senha, |
| 2 | topicoStatus, 1, true, "offline"); |
The extra parameters are the will topic, quality of service, retained flag and message. As soon as it connects, publish online to the same topic, also retained:
| 1 | mqtt.publish(topicoStatus, "online", true); |
The result is a reliable presence topic. While the device is alive it says online; if it disappears, the broker itself publishes offline after the keepalive expires. The system on the other side can now distinguish old data from a fallen device, which is a distinction that saves a support shift.
This tutorial's firmware does not use Last Will yet — it is a worthwhile improvement, and the kit includes a ready example.
Tutorial: from zero to automatic reconnection
Step 1: prepare the project
Create the project in PlatformIO and declare the only external dependencies:
| 1 | [env:esp32-mqtt] |
| 2 | platform = espressif32 |
| 3 | board = esp32dev |
| 4 | framework = arduino |
| 5 | monitor_speed = 115200 |
| 6 | lib_deps = |
| 7 | knolleary/PubSubClient@^2.8 |
| 8 | bblanchon/ArduinoJson@^7.2.1 |
Step 2: adjust the parameters
At the top of the kit firmware, provide the network, broker and device identifier. Use a different identifier on every board — that is what prevents the drop cycle described above.
If your broker does not require authentication, leave the username and password empty.
Step 3: flash and observe normal behavior
| 1 | pio run -t upload |
| 2 | pio device monitor -b 115200 |
You should see the Wi-Fi connection, then the MQTT connection, and telemetry arriving at a regular interval. Up to here, this is the easy case.
Step 4: the important test — take down the broker
With everything working, stop the broker. If it is a local Mosquitto instance, stop the service.
Watch the serial monitor. The correct behavior is: the device detects the failure, tries every five seconds, prints the rc for each attempt, and continues doing everything else — telemetry keeps being generated and the web server keeps responding.
If the firmware freezes here, the guard pattern has not been applied.
Step 5: bring the broker back
Restart the broker and observe it. Reconnection should happen within five seconds, without intervention and without restarting the board.
Step 6: take down Wi-Fi
Now turn off the router or access point. The WiFi.status() guard should prevent MQTT attempts while there is no network — you should not see a stream of negative rc values, but MQTT silence while Wi-Fi tries every thirty seconds.
Turn the router back on. The correct sequence is Wi-Fi first, MQTT immediately after.
Step 7: verify keepalive
Leave it running for twenty minutes without publishing anything. If the connection drops by itself during that interval, mqtt.loop() is not being called regularly — look for a long delay in your loop.
Common errors and solutions
The device freezes when the broker goes down. There is still a while loop or a delay in the connection routine. Apply the guard pattern.
It reconnects and drops in a cycle, with no clear error. Duplicate client id. Confirm that every board uses a different identifier.
It drops by itself after a few minutes while idle. mqtt.loop() is not being called regularly, or a long delay is blocking keepalive.
Constant rc=-2. The broker name does not resolve. Test with the IP directly to separate a DNS problem from an address problem.
rc=4 or rc=5. Credentials. With a token-based broker, check validity before suspecting the code.
It publishes but receives nothing. The topics were not subscribed to after reconnecting. A subscription does not survive a disconnect, which is why subscribe is inside the successful connect block.
It works with one device and fails with several. Besides the client id, check the broker connection limit and the PubSubClient buffer size, which is small by default and truncates larger messages.
