12/1/2025
Embedded C++ on ESP32 — patterns that save your project from WDT resets
TL;DR: WDT (Watchdog Timer) resets ESP32 when loop() blocks too long. 3 patterns: non-blocking delays (millis() instead of delay()), FreeRTOS tasks, yield() in long loops.
If your ESP32 is randomly resetting with the message E (5012) task_wdt: Task watchdog got triggered — you’ve encountered the watchdog. This is not a library bug or hardware issue. It’s a safety mechanism informing you that your code is blocking the processor for too long. The solution is not disabling the watchdog — it’s rewriting the code to use non-blocking patterns.
What is a Watchdog Timer
WDT is a hardware timer independent of firmware. Its job is simple: if firmware doesn’t “feed” the watchdog (doesn’t reset the timer) within a set time, WDT assumes the firmware has hung and resets the MCU. On ESP32, Task WDT waits 5 seconds by default.
Why this matters: without WDT, a hung firmware = a locked device until manual reset. In embedded systems that run unattended (field sensors, IoT devices) WDT is the only self-recovery mechanism. Don’t disable it.
ESP32 has two watchdogs: Task WDT (monitors FreeRTOS tasks, default 5s) and Interrupt WDT (shorter, for interrupts). Here we’ll focus on Task WDT — it’s the one that most commonly triggers.
delay() = death
void loop() {
readSensor();
delay(5000); // wait 5 seconds
sendToServer();
}
delay(5000) literally stops code execution for 5 seconds. Task WDT is not fed for those 5 seconds. Result after 5 seconds: E (5012) task_wdt: Task watchdog got triggered. The following tasks did not reset the watchdog in time: loopTask. Reset.
Even delay(4999) is problematic — if readSensor() or sendToServer() take a moment longer, you exceed the limit.
Non-blocking pattern with millis()
Instead of waiting — check if enough time has passed:
// Old code with delay
void loop() {
Serial.println("Sensor reading: " + String(analogRead(A0)));
delay(5000);
}
// New code with millis()
unsigned long lastSensorRead = 0;
const unsigned long SENSOR_INTERVAL = 5000;
void loop() {
unsigned long now = millis();
if (now - lastSensorRead >= SENSOR_INTERVAL) {
lastSensorRead = now;
Serial.println("Sensor reading: " + String(analogRead(A0)));
}
// loop() returns immediately — WDT is fed on every pass
}
millis() returns the time since ESP32 startup in milliseconds. The pattern if (millis() - lastTime >= interval) is the foundation of non-blocking embedded code. loop() returns immediately after each pass, FreeRTOS has time to feed the watchdog.
You can have multiple independent timers:
unsigned long lastSensor = 0, lastHeartbeat = 0;
void loop() {
unsigned long now = millis();
if (now - lastSensor >= 5000) {
lastSensor = now;
readAndSendSensor();
}
if (now - lastHeartbeat >= 30000) {
lastHeartbeat = now;
sendHeartbeat();
}
}
FreeRTOS tasks on ESP32
For long operations (HTTP request, SD card write, OTA update) even the millis() pattern may not be enough — the operations themselves can block for longer than 5 seconds. FreeRTOS tasks are the right solution: each task has its own watchdog.
void httpTask(void* parameter) {
for (;;) {
// HTTP request may take several seconds — that's OK, task has its own WDT
HTTPClient http;
http.begin("https://api.example.com/data");
int code = http.GET();
if (code > 0) {
processResponse(http.getString());
}
http.end();
vTaskDelay(pdMS_TO_TICKS(30000)); // wait 30s (FreeRTOS-aware delay)
}
}
void setup() {
xTaskCreatePinnedToCore(
httpTask, // task function
"HttpTask", // name (for debugging)
8192, // stack size in bytes
NULL, // parameters
1, // priority (0-25, higher = more important)
NULL, // handle
1 // core (0 or 1, ESP32 has two cores)
);
}
vTaskDelay() instead of delay() — FreeRTOS-aware delay that allows other tasks to run during the wait.
WiFi reconnect without blocking
// Bad — can block loop for minutes if WiFi doesn't respond
void connectWifi() {
WiFi.begin(SSID, PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500); // WDT trigger with bad WiFi
}
}
// Good — non-blocking reconnect
unsigned long wifiCheckLast = 0;
bool wifiConnecting = false;
void loop() {
if (WiFi.status() != WL_CONNECTED) {
if (!wifiConnecting) {
WiFi.begin(SSID, PASS);
wifiConnecting = true;
}
if (millis() - wifiCheckLast >= 500) {
wifiCheckLast = millis();
Serial.print("."); // progress indicator
}
return; // don't run rest of loop until WiFi is available
}
wifiConnecting = false;
// rest of logic (only when WiFi is connected)
}
esp_task_wdt_reset()
In exceptional cases — a long, intentional loop that can’t be divided into fragments — you can manually feed the watchdog:
#include "esp_task_wdt.h"
void parseLargeJson(const String& json) {
// Parsing 100KB JSON may take >5s on ESP32
for (int i = 0; i < json.length(); i++) {
processChar(json[i]);
if (i % 1000 == 0) {
esp_task_wdt_reset(); // feed watchdog every 1000 characters
}
}
}
This is a last-resort solution — not the first option. Manually feeding the WDT hides the problem instead of solving it: if a function genuinely takes 10 seconds, it’s better to optimize it or move it to a separate task.
Summary
Three patterns to remember: millis() instead of delay() for timers, vTaskDelay() in FreeRTOS tasks, xTaskCreatePinnedToCore() for long background operations. These three patterns eliminate 95% of WDT problems. esp_task_wdt_reset() is a tool for exceptional situations, not for masking architectural problems.