Why Your ESP32 OTA Update Silently Reboots: Run It in Its Own Task

An OTA download that resets the board with no panic and no log line is almost always the task watchdog. The cure is a dedicated FreeRTOS task, not a bigger timeout.

The first version of the remote-update feature on my ESP32-P4 robot looked fine on paper. An MQTT message arrives with a firmware URL, the handler schedules the upgrade onto the main event loop, the existing Ota::StartUpgrade() routine downloads the binary and writes it to the inactive partition. Simple.

In practice: the device started the download, got partway through writing flash, and hard-reset with no output at all. No Guru Meditation, no backtrace, no error message. Just a reboot and the old firmware coming back up.

Why there is no log

A crash on the ESP32 normally produces a panic dump. A silent reset is a different animal, and in an ESP-IDF project it has a short list of causes:

You can distinguish them on the next boot with esp_reset_reason(), which returns ESP_RST_TASK_WDT, ESP_RST_INT_WDT, ESP_RST_BROWNOUT and so on. Logging that value at startup costs nothing and saves a lot of guessing.

In my case it was the task watchdog.

What went wrong

An OTA update is a long, blocking operation: an HTTP download of a few megabytes followed by sequential flash writes. Running it inside the main event loop meant that loop stopped servicing anything else for tens of seconds. The task watchdog, which expects the main task to check in regularly, fired and reset the chip.

There is a second problem hiding behind the first one: the main task’s stack was sized for event dispatch, not for a TLS-capable HTTP client plus a flash-write buffer. Even without the watchdog, this was a stack overflow waiting to happen.

16 MB flash bootloader ptable nvs/phy otadata ota_0 · 6 MB running v2.2.8 ota_1 · 6 MB previous / target bootloader: one copy, never rewritten by OTA otadata → boot from here mqtt_ota task own 8 KB stack not on the TWDT HTTP GET xiaozhi.bin 3.3 MB write ① ② flip otadata → ota_1 ③ reboot, confirm, done next update goes to ota_0 Run in the main loop: TWDT → silent reset
Flash layout with the two OTA slots. The download and flash write run in their own task; otadata is flipped only after the image is verified.

The fix

Give the update its own task with its own stack:

static void ota_task(void *arg) {
    char *url = (char *)arg;
    ota.StartUpgrade(url);   // blocking; may take a minute
    // StartUpgrade reboots on success; reaching here means it failed
    free(url);
    vTaskDelete(NULL);
}

void OnUpgradeCommand(const char *url) {
    char *copy = strdup(url);
    xTaskCreate(ota_task, "mqtt_ota", 8192, copy, 5, NULL);
}

Points that matter:

After this change the same firmware image downloaded 3.3 MB in 22 seconds at 100-160 KB/s, wrote it to ota_1, rebooted, and the heartbeat reported the new version.

While you are here: how the two OTA slots actually work

Two misconceptions I had going in, corrected:

  1. The bootloader is not duplicated. There is one bootloader and OTA never writes to it, so an OTA cannot brick the boot path.
  2. The two app slots are not “primary and backup”. They alternate. If you are running from ota_0, the new image goes to ota_1, the small otadata partition is updated to point at it, and the chip reboots. Next update goes the other way. At any moment one slot is live and the other holds the previous version, which is what makes rollback possible.

The general rule

Anything that blocks for more than a fraction of a second does not belong in the main event loop.

Candidates I now move to their own task by reflex: OTA, large file reads and writes, long HTTP requests, dense number-crunching. And when a board reboots without a panic message, the task watchdog goes to the top of the suspect list, before hardware, before power, before everything.