An ESP32 task watchdog reset in an MQTT project almost always means some piece of code is blocking a task for longer than the watchdog allows. This article shows how to read the task_wdt message, identifies the usual culprits in MQTT sketches, and gives you non-blocking patterns that keep the device connected without resets.
What the Task Watchdog Actually Does
The ESP32 runs FreeRTOS. Even in an Arduino sketch, setup() and loop() run inside a FreeRTOS task called loopTask, Wi-Fi and the TCP/IP stack run in their own tasks, and each CPU core has an idle task that runs when nothing else needs the CPU.
The Task Watchdog Timer (TWDT) monitors a set of subscribed tasks. Each subscribed task must "feed" the watchdog periodically. If one of them fails to do so within the timeout, the TWDT prints an error and, depending on configuration, panics and reboots the chip.
Which tasks are watched depends on your environment:
| Environment | Watched by default | Typical timeout |
|---|---|---|
| ESP-IDF | Idle tasks of both cores (configurable in menuconfig) | 5 s |
| Arduino-ESP32 | Idle task on core 0; loopTask only if you call enableLoopWDT() |
5 s |
| AsyncTCP (used by AsyncMqttClient) | Its async_tcp task, when built with its watchdog option enabled |
Inherits TWDT timeout |
The idle task is watched for an indirect reason: if it never gets to run, some other task on that core is hogging the CPU without yielding. So an "IDLE0" message does not mean the idle task is broken, it means something else on core 0 is spinning.
There is also a separate Interrupt Watchdog, which fires when interrupts are disabled too long or an ISR runs too long. Its message mentions "Interrupt wdt timeout" and has different causes, mostly in ISR code. This article is about the task watchdog.
Reading the Error Message
A typical crash looks like this:
E (25391) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time:
E (25391) task_wdt: - IDLE0 (CPU 0)
E (25391) task_wdt: Tasks currently running:
E (25391) task_wdt: CPU 0: mqttTask
E (25391) task_wdt: CPU 1: loopTask
E (25391) task_wdt: Aborting.
Read it in two parts. The first list says who starved: here the idle task of core 0. The "currently running" list says who was holding the CPU at that moment: a task called mqttTask on core 0. That is your suspect. If the starved task is async_tcp, the problem is code running inside an AsyncTCP or AsyncMqttClient callback. If it is loopTask, your loop() blocked while the loop watchdog was enabled.
Enable the exception decoder in your serial monitor (in PlatformIO, monitor_filters = esp32_exception_decoder) to turn the backtrace addresses into file names and line numbers.
The Usual Culprits in ESP32 Task Watchdog MQTT Crashes
Busy-Wait Loops Without a Yield
The classic offender is a wait loop with nothing inside:
while (WiFi.status() != WL_CONNECTED) { } // never yields
while (!mqtt.connect(clientId)) { } // retries at full speed
In loop() on core 1 this often gets away without a reset, because the Arduino core does not watch idle task 1 by default. Move the same code into a task pinned to core 0, or enable the loop watchdog, and it resets after five seconds. A delay(10) or vTaskDelay() inside the loop fixes the starvation but not the design: the device still cannot do anything else while it waits.
Blocking Connect Calls Longer Than the Timeout
PubSubClient's connect() is blocking. If the broker is unreachable, the underlying TCP connect and the MQTT CONNACK wait can together take longer than five seconds, especially with TLS, where the handshake alone can take seconds on a weak signal. A reconnect function that calls connect() several times in a row multiplies that. Lower the socket timeout with mqtt.setSocketTimeout(4) and try only once per call.
Heavy Work Inside MQTT Callbacks
With AsyncMqttClient, onMessage runs inside the async_tcp task. Parsing a large JSON document, writing to flash, driving a display or calling delay() there blocks the network stack and can trip that task's watchdog. With PubSubClient, the callback runs inside mqtt.loop(), so heavy work there blocks the loop instead. In both cases, copy the payload, set a flag or push to a queue, and do the work elsewhere.
Long Blocking Sensor Reads and Flash Writes
Some sensor libraries poll with busy waits, and an OTA download or a large flash write can run for seconds. These are fine in their own task that feeds the watchdog, but not on the same task that services MQTT.
Reconnect Storms After Network Loss
When Wi-Fi drops, a naive sketch calls WiFi.begin() and mqtt.connect() back to back in a tight loop. Each call blocks a little, the loop never pauses, and eventually the watchdog fires. Exponential backoff solves both the watchdog problem and the load on the broker.
Non-Blocking MQTT Pattern for loop()
This sketch keeps loop() short at all times. Every connection attempt happens at most once per backoff interval, the callback only copies data, and the work runs in loop() afterwards. It connects to the public area of broker.connectmq.com.
#include <WiFi.h>
#include <PubSubClient.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* CMD_TOPIC = "connectmq/demo/b52e07/cmd";
const char* TELEMETRY_TOPIC = "connectmq/demo/b52e07/telemetry";
WiFiClient net;
PubSubClient mqtt(net);
String clientId;
volatile bool commandPending = false;
char commandBuf[128];
unsigned long nextMqttAttempt = 0;
unsigned long mqttBackoff = 1000;
unsigned long lastPublish = 0;
void onMessage(char* topic, byte* payload, unsigned int length) {
size_t n = min((size_t)length, sizeof(commandBuf) - 1);
memcpy(commandBuf, payload, n);
commandBuf[n] = '\0';
commandPending = true; // no heavy work here
}
void handleCommand(const char* cmd) {
Serial.printf("Handling command: %s\n", cmd);
// slow work is fine here, as long as it stays under the watchdog timeout
}
void maintainMqtt() {
if (WiFi.status() != WL_CONNECTED) return; // Wi-Fi reconnects on its own
if (mqtt.connected()) { mqtt.loop(); return; }
if (millis() < nextMqttAttempt) return;
if (mqtt.connect(clientId.c_str())) {
mqtt.subscribe(CMD_TOPIC);
mqttBackoff = 1000;
} else {
nextMqttAttempt = millis() + mqttBackoff;
mqttBackoff = min(mqttBackoff * 2, 30000UL);
}
}
void setup() {
Serial.begin(115200);
enableLoopWDT(); // watch loop() too, so blocking shows up in testing
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASS);
clientId = "esp32-wdt-" + String((uint32_t)ESP.getEfuseMac(), HEX);
mqtt.setServer("broker.connectmq.com", 1883);
mqtt.setSocketTimeout(4); // keep each blocking connect well under 5 s
mqtt.setCallback(onMessage);
}
void loop() {
maintainMqtt();
if (commandPending) {
commandPending = false;
handleCommand(commandBuf);
}
if (mqtt.connected() && millis() - lastPublish > 10000) {
lastPublish = millis();
char payload[64];
snprintf(payload, sizeof(payload), "{\"uptime\":%lu,\"heap\":%u}",
millis() / 1000, ESP.getFreeHeap());
mqtt.publish(TELEMETRY_TOPIC, payload);
}
}
The Arduino core feeds the loop watchdog automatically each time loop() returns, so as long as one pass through loop() stays well under the timeout, enabling it costs nothing and catches regressions early. Turning it on during development is a cheap way to find the blocking call before it reaches the field.
Running MQTT in Its Own FreeRTOS Task
If you prefer a dedicated MQTT task, subscribe it to the watchdog and reset it in every iteration. The esp_task_wdt API in ESP-IDF 5 (used by Arduino-ESP32 3.x) looks like this:
#include <esp_task_wdt.h>
void mqttTask(void* arg) {
esp_task_wdt_add(NULL); // watch this task
for (;;) {
maintainMqtt(); // must return quickly
esp_task_wdt_reset(); // feed the watchdog
vTaskDelay(pdMS_TO_TICKS(10)); // always yield
}
}
void setup() {
// ... Wi-Fi and mqtt setup as above ...
xTaskCreatePinnedToCore(mqttTask, "mqttTask", 6144, NULL, 1, NULL, 1);
}
Keep the priority low (1 is the same as loopTask) unless you have a reason to raise it; a high-priority task that never blocks starves everything beneath it. Pin it to core 1 if you want to stay off the core where Wi-Fi runs. If the MQTT client is used from more than one task, protect it with a mutex, because PubSubClient is not thread-safe. The ESP32 FreeRTOS MQTT guide covers queues and task design in more detail.
Should You Raise the Watchdog Timeout?
Sometimes, but treat it as a last step. Raising the timeout hides blocking code rather than fixing it, and the device becomes slower to recover from a genuine hang. Legitimate cases are long, unavoidable operations such as an OTA flash write. In ESP-IDF 5 you can reconfigure the TWDT at runtime:
esp_task_wdt_config_t cfg = {
.timeout_ms = 15000,
.idle_core_mask = (1 << 0), // keep watching IDLE0
.trigger_panic = true,
};
esp_task_wdt_reconfigure(&cfg);
A cleaner approach for a single long operation is to feed the watchdog from inside it, for example from an OTA progress callback, and leave the global timeout alone. Never disable the watchdog entirely in production. It is the only thing that reboots a device stuck in a deadlock in the field.
Troubleshooting Checklist
- Read which task starved and which task was running; that pair tells you where to look.
- Decode the backtrace with the exception decoder to get the exact line.
- Search the code for
whileloops withoutdelay(),yield()orvTaskDelay(). - Set
mqtt.setSocketTimeout()to a few seconds and remove retry loops aroundconnect(). - Move JSON parsing, flash writes and display updates out of MQTT callbacks.
- Add exponential backoff to Wi-Fi and MQTT reconnects.
- Enable
enableLoopWDT()during testing so blocking inloop()shows up early. - Watch the device's uptime over MQTT: a counter that keeps resetting is a watchdog or brownout reboot.
Publishing uptime and esp_reset_reason() after every boot is a simple habit that turns invisible resets into data. Many connection problems that look like watchdog issues are actually network problems underneath; if the device never connects in the first place, work through why an ESP32 cannot connect to an MQTT broker first, and for reconnection strategy see the ESP32 MQTT reconnect guide.
Monitor Your Devices on ConnectMQ
The test sketch above runs against the ConnectMQ free public MQTT broker, which is perfect for reproducing a reset on your desk. For a fleet, a private ConnectMQ broker lets you watch every device's telemetry in the live topic explorer and set threshold alert rules on values such as free heap, so a device that starts rebooting stands out quickly. You can create one on the free Serverless plan at app.connectmq.com without a credit card. If you are still deciding where to host your broker, the cloud MQTT broker guide compares the options, and the ESP32 IoT tutorial shows a complete non-blocking device project end to end.
Frequently Asked Questions
What does "Task watchdog got triggered" with IDLE0 mean?
A task running on core 0 kept the CPU busy for longer than the timeout without yielding, so the idle task never ran. The "Tasks currently running" line names the likely culprit.
Does delay() reset the watchdog?
delay() yields to other tasks, which lets the idle task run, so it prevents IDLE starvation. It does not feed a task you subscribed with esp_task_wdt_add(); that task must call esp_task_wdt_reset() itself.
Why does AsyncMqttClient trigger the async_tcp watchdog?
Its callbacks run inside the async_tcp task. Long work there blocks the task, so keep callbacks short and hand the payload to loop() or a separate task through a flag or queue.
Is it safe to disable the task watchdog?
Not in production. It is the mechanism that recovers a device from a hang. Fix the blocking code, and only raise the timeout for genuinely long operations.



