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:
- Task watchdog (TWDT) timing out on a task that is subscribed to it
- Interrupt watchdog if interrupts are blocked too long
- Brown-out if the supply sags
- Software reset called explicitly
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.
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:
- Stack size. 8 KB was enough here for a plain HTTP download; TLS needs more. If the task later crashes with a stack canary message, that is the number to raise.
- Copy the argument. The MQTT payload buffer that held the URL is gone by the time the task runs.
- Don’t subscribe the OTA task to the watchdog, or if you must, feed it inside the download loop. The default TWDT configuration only watches the idle tasks and whatever you explicitly add, so a fresh task is usually safe.
- Priority. Slightly above the main loop so the download is not starved by UI work, but below anything real-time (motor control, audio).
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:
- The bootloader is not duplicated. There is one bootloader and OTA never writes to it, so an OTA cannot brick the boot path.
- The two app slots are not “primary and backup”. They alternate. If you are running from
ota_0, the new image goes toota_1, the smallotadatapartition 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.