perkun.eu Services Portfolio Blog About Contact PL
← Blog

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.