Boot failures · Flashing · All ESP32 families

ESP32 boot loop: invalid header: 0xffffffff

Your board resets every few hundred milliseconds and the serial console repeats the same line forever. Nothing you change in your sketch makes any difference — because the chip never gets as far as your sketch. Here is what the ROM loader is actually telling you, and the three things that cause it.

Applies to: ESP32, S2, S3, C2, C3, C6, H2 Toolchain: any Published 8 August 2026

What the message actually means

The line looks like this, repeating until you pull the power:

ets Jul 29 2019 12:21:46

rst:0x10 (RTCWDT_RTC_RESET),boot:0x13 (SPI_FAST_FLASH_BOOT)
invalid header: 0xffffffff
invalid header: 0xffffffff
invalid header: 0xffffffff

An ESP32 boots in two stages. The first-stage bootloader is in ROM — it is burned into the silicon, you cannot break it, and it is what prints those lines. Its job is to load the second-stage bootloader from a fixed offset in SPI flash, and it sanity-checks what it finds there before jumping to it. Every ESP32 image begins with a header whose first byte is the magic value 0xE9.

0xffffffff is what you read back from erased NOR flash. So the message is precise and literal: at the address where my bootloader is supposed to be, the flash is blank. The ROM loader gives up, the watchdog resets the chip, and the whole thing runs again — forever.

This is never a bug in your application code. Your setup() has not run. Neither has the second-stage bootloader. Nothing you write in your sketch, no library you add or remove, and no compiler flag in your app can change this message. Stop debugging your firmware and start looking at what got written to flash.

A related message, invalid header: 0x00000000, means the same thing with a different filling: zeroes rather than 0xFF. Some other value entirely (invalid header: 0x0a1b2c3d) means the loader found data there, just not an image header — usually an image written at the wrong offset, which is cause 2.

Read the whole boot log, not just the loop

Reset the board with a serial monitor attached at 115200 baud and read the first three lines before the loop starts. They carry more diagnostic weight than the repeated line does.

What the preamble tells you
FieldExampleWhat to take from it
ets dateets Jul 29 2019 The ROM build date. It differs per chip family, so it is a rough way to confirm which chip you are actually talking to.
rst:0x10 (RTCWDT_RTC_RESET) Why the chip last reset. A watchdog reset here is the ROM loader failing and being restarted — expected in this loop.
boot:0x13 (SPI_FAST_FLASH_BOOT) The strapping-pin boot mode. This says it tried to boot from flash. If you see DOWNLOAD_BOOT instead, GPIO0 is held low and the board is waiting to be flashed, which is a different situation.

If you get no output at all rather than a loop, you have a different problem: wrong baud rate, the wrong serial port, a charge-only USB cable, or a board whose USB bridge is not powered. Confirm you can see the ROM banner before treating this article as relevant.

Cause 1 — the image was built for a different chip

This is by far the most common cause, and it catches experienced people because both halves of the mistake look fine in isolation: the build succeeded, and the flash wrote without error.

The catch is that ESP32 families do not all boot from the same flash offset.

Second-stage bootloader offset by chip family
ChipBootloader offsetChip ID in the image header
ESP32 (classic)0x10000x0000
ESP32-S20x10000x0002
ESP32-S30x00x0009
ESP32-C20x00x000c
ESP32-C30x00x0005
ESP32-C60x00x000d
ESP32-H20x00x0010

Now trace what happens when you build for a classic ESP32 and flash the result to an ESP32-S3. Your merged image places the bootloader at 0x1000, because that is where a classic ESP32 looks for it. The S3 looks at 0x0. Nothing was ever written there, so it reads 0xFF bytes — and prints invalid header: 0xffffffff.

FLASH CONTENTS (image built for classic ESP32) 0x0 0xFF FF FF FF 0x1000 0xE9 <bootloader> 0x8000 part. 0x10000 app classic ESP32 reads here finds 0xE9 — boots ESP32-S3 reads here — blank
Fig. 1 — Same flash contents, two chips, two different read offsets. The image is not wrong; it is wrong for this board.

The reverse direction fails too, for a subtler reason. Flash an S3-targeted image to a classic ESP32 and the bootloader lands at 0x0, while the classic part reads 0x1000 — which now holds the middle of the bootloader binary rather than blank flash. That usually produces invalid header: followed by whatever bytes happened to be there, not 0xffffffff.

Why the mistake is so easy to make. In most workflows the target chip is chosen at build time, but the board is not physically connected until flash time — minutes later, in a different dialog. Nothing forces the two to agree. Arduino IDE defaults to plain "ESP32 Dev Module"; if you plug in an S3 and never revisit the Tools → Board menu, you will build a classic image and flash it to an S3 without a single warning.

Cause 2 — the image was written to the wrong offset

This one is self-inflicted, usually by a copied-and-pasted esptool command line. There are two distinct kinds of artifact and they are flashed differently:

  • A merged image (often merged.bin or *.factory.bin) already contains the bootloader, the partition table and the app at their correct internal offsets. It goes to 0x0for every chip, including the classic ESP32. The 0x1000 gap is baked into the file itself.
  • Separate binaries (bootloader.bin, partitions.bin, firmware.bin) each need their own offset, and the bootloader's offset is the chip-specific one from the table above.

Writing a merged image to 0x1000 on a classic ESP32 — a very common copy-paste error, because 0x1000 is the number everyone remembers — shifts everything by 4 KB and leaves 0x1000 holding the front of the file rather than the bootloader.

# Merged image: always 0x0, on every chip
esptool.py --chip esp32s3 --port /dev/ttyUSB0 write_flash 0x0 merged.bin

# Separate binaries on a classic ESP32: bootloader at 0x1000
esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash \
  0x1000 bootloader.bin 0x8000 partitions.bin 0x10000 firmware.bin

# Separate binaries on an S3/C3/C6: bootloader at 0x0
esptool.py --chip esp32s3 --port /dev/ttyUSB0 write_flash \
  0x0 bootloader.bin 0x8000 partitions.bin 0x10000 firmware.bin
Command spellings. Newer esptool releases prefer hyphenated subcommands (write-flash, erase-flash, chip-id); the underscore spellings above are the long-standing ones and are still widely accepted. Use whichever your installed version documents in esptool.py --help.

Cause 3 — the write never completed

Less common, but worth ruling out before you rebuild anything. If the flash write was interrupted or corrupted, the first sectors may genuinely be erased even though your build and your offsets are both correct. Suspects:

  • A charge-only USB cable, or a marginal one. It enumerates, it powers the board, and it drops bytes under sustained throughput.
  • An over-ambitious baud rate. If you are flashing at 921600 and seeing intermittent failures, drop to 115200 and try again. A slow successful flash beats a fast corrupt one.
  • Insufficient power. Boards that brown out mid-write — particularly ones with a camera, a motor driver or a display attached — can reset partway through. Disconnect peripherals and flash the bare board.
  • An erase that succeeded where the write then failed. esptool erases before writing, so an aborted run leaves you with exactly this symptom. Scroll back and confirm the flash actually finished, with a "Hash of data verified" line for each region.

Confirm which one you have

Two commands settle it. First, ask the board what it is — not what you think it is:

esptool.py --port /dev/ttyUSB0 chip_id

That reports the connected chip (for example Detecting chip type... ESP32-S3) without writing anything. Compare it to the target you built for. If they differ, you have cause 1 and you are done diagnosing.

If they match, check the image itself. Every ESP32 image header begins with 0xE9, and the extended header carries a 16-bit chip ID at offset +12 from the magic byte. So you can read the target chip straight out of the binary before you flash it:

# Byte 0 should be e9 for a merged image (or byte 0x1000 for a
# classic-ESP32 layout). Bytes +12/+13 are the chip id, little-endian.
xxd -l 16 merged.bin

# 00000000: e900 0220 ... 0900 ....   <- 0x0009 = ESP32-S3

An image whose first byte is not 0xE9 at either 0x0 or 0x1000 is not a bootable ESP32 image at all — you may be flashing an ELF, an OTA-only app binary, or a partially downloaded file.

Finally, read back what is actually on the chip:

esptool.py --port /dev/ttyUSB0 read_flash 0x0 0x20 head.bin && xxd head.bin

All ff? The flash really is blank there, and the question is only whether your image put the bootloader somewhere else (cause 1 or cause 2) or never landed at all (cause 3).

Recover the board

The board is not bricked. The ROM loader lives in silicon and always comes back, so a board in this loop is fully recoverable — there is no state you can write to flash that stops you re-flashing it.

  1. Put it in download mode. Hold BOOT (GPIO0), tap RESET/EN, release BOOT. Many dev boards do this automatically over the USB bridge, but doing it by hand removes a variable. The banner should change to DOWNLOAD_BOOT.
  2. Erase the flash completely. esptool.py --port /dev/ttyUSB0 erase_flash. This clears any half-written image and any stale partition table, both of which can otherwise confuse the next attempt.
  3. Rebuild for the chip you actually have. Not the one the IDE defaulted to. In Arduino IDE that is Tools → Board; with ESP-IDF, idf.py set-target esp32s3 followed by a clean build.
  4. Flash at the right offset per cause 2, at 115200 if you have any doubt about the cable.
  5. Watch the reboot. A healthy boot shows the ROM banner, then a ESP-ROM:... / entry 0x... line, then your own output.
If erase_flash itself fails with a connection error, the problem is the serial link rather than the image: wrong port, a driver missing for the CP210x/CH340 bridge, another program holding the port open, or the board not being in download mode.

Stop it happening again

The structural fix is to make the build target and the connected board verify each other rather than trusting yourself to remember. Whatever you build with:

  • Run chip_id before a flash in any script that does unattended flashing, and fail the script when it disagrees with the build target.
  • Let esptool auto-detect. If you omit --chip, it detects the connected part — and refuses to write an image whose header targets a different one. Pinning --chip to the wrong value is how that safety net gets disabled.
  • Keep one board per build directory when you work with several ESP32 variants. Most of these incidents are a stale target left over from the last project.

For what it is worth, this is a check we added to ESP-GenUI after hitting it ourselves: before writing anything, it reads the chip ID out of the image header (the 0xE9 magic and the +12 field described above), compares it to the chip reported by the connected board, and blocks the flash on a mismatch rather than letting you discover it from the serial console afterwards. The technique is not specific to our tool — it is twenty lines in any language, and worth adding to your own flashing scripts.