You mounted your ESP32 on the terrace to monitor weather, buried it inside a junction box at your factory, or zip-tied it to a pipe in a greenhouse. Everything works perfectly — until you find a bug, want to add a feature, or need to patch a security hole. Now you have to climb a ladder, unscrew the enclosure, disconnect the board, carry it to your desk, plug in a USB cable, flash the firmware, reassemble everything, and pray you did not break a wire in the process.
There is a better way. OTA (Over-The-Air) updates let you upload new firmware to your ESP32 over WiFi. No USB cable, no physical access, no ladders. In this guide, we will walk through three complete, working OTA methods — from the simplest local-network approach to a fully automated server-based system suitable for production fleets.
What is OTA and Why It Matters
OTA updating means transferring a new firmware binary to your ESP32 through its WiFi connection. The ESP32 downloads the binary, writes it to a spare flash partition, verifies it, and reboots into the new code. If the new code is broken, the bootloader can roll back to the previous version.
Why you need OTA from day one:
- Deployed devices are hard to reach. A sensor node on a cell tower or inside a sealed IP67 box is not something you casually re-flash.
- Bug fixes cannot wait. A memory leak that crashes your device every 48 hours needs a patch now, not next month when someone visits the site.
- Feature iteration is faster. Push a new feature to 50 devices in minutes instead of spending a weekend driving to each location.
- Security patches are critical. If a vulnerability is found in your MQTT library, you need to patch every device in the field immediately.
The golden rule: always include OTA capability in the very first firmware you flash via USB. If your initial firmware does not support OTA, you will need physical access forever.
ESP32 Partition Table: How OTA Works Under the Hood
Before diving into code, it helps to understand what happens in flash memory during an OTA update.
The ESP32's flash is divided into partitions. A typical OTA-enabled partition table looks like this:
| Partition | Type | Offset | Size | Purpose |
|---|---|---|---|---|
| nvs | data | 0x9000 | 20 KB | Non-volatile storage (WiFi credentials, settings) |
| otadata | data | 0x0e000 | 8 KB | Tracks which app partition is active |
| app0 | app | 0x10000 | 1.25 MB | First application slot |
| app1 | app | 0x150000 | 1.25 MB | Second application slot |
| spiffs | data | 0x290000 | 1.5 MB | File system for web assets, configs |
Here is the key insight: the ESP32 maintains two application slots. Your running firmware lives in one slot (say app0). When an OTA update arrives, the new firmware is written to the other slot (app1). Once the write is complete and verified, the bootloader is told to boot from app1 next time. If the new firmware crashes before marking itself as valid, the bootloader automatically rolls back to app0.
This dual-partition scheme means an interrupted or corrupt update will not brick your device. The old firmware remains intact in the other slot.
Important: With two 1.25 MB app slots, your compiled firmware binary must be under ~1.25 MB. If your code is too large, you will need a custom partition table with larger app partitions (and a smaller SPIFFS partition, or a module with more flash).
Method 1: Arduino OTA (Local Network Discovery)
This is the simplest approach. Your ESP32 advertises itself on the local network using mDNS. The Arduino IDE (or PlatformIO) discovers it and lets you upload firmware over WiFi just like you would over USB.
Best for: Development, prototyping, devices on the same network as your computer.
Complete Code
#include <WiFi.h>
#include <ArduinoOTA.h>
const char* ssid = "YourWiFiSSID";
const char* password = "YourWiFiPassword";
void setup() {
Serial.begin(115200);
Serial.println("Booting...");
// Connect to WiFi
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
while (WiFi.waitForConnectResult() != WL_CONNECTED) {
Serial.println("WiFi connection failed. Rebooting...");
delay(5000);
ESP.restart();
}
Serial.print("Connected. IP address: ");
Serial.println(WiFi.localIP());
// ---- ArduinoOTA Configuration ----
// Set a hostname (visible in Arduino IDE and on the network)
ArduinoOTA.setHostname("esp32-weather-station");
// Set a password to prevent unauthorized uploads
ArduinoOTA.setPassword("wavtron2026");
// Optional: set the OTA port (default is 3232 for ESP32)
ArduinoOTA.setPort(3232);
ArduinoOTA.onStart([]() {
String type;
if (ArduinoOTA.getCommand() == U_FLASH) {
type = "firmware";
} else {
type = "filesystem";
}
Serial.println("OTA Start: updating " + type);
});
ArduinoOTA.onEnd([]() {
Serial.println("\nOTA End. Rebooting...");
});
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
Serial.printf("OTA Progress: %u%%\r", (progress / (total / 100)));
});
ArduinoOTA.onError([](ota_error_t error) {
Serial.printf("OTA Error [%u]: ", error);
if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed");
else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
else if (error == OTA_END_ERROR) Serial.println("End Failed");
});
ArduinoOTA.begin();
Serial.println("ArduinoOTA ready.");
}
void loop() {
// CRITICAL: This must be called frequently
ArduinoOTA.handle();
// Your application code goes here
// Example: read a sensor every 10 seconds
static unsigned long lastRead = 0;
if (millis() - lastRead > 10000) {
lastRead = millis();
Serial.println("Reading sensor...");
// readSensor();
}
}
How to Upload from Arduino IDE
- Flash the code above via USB the first time.
- In Arduino IDE, go to Tools > Port. You will see a new entry like
esp32-weather-station at 192.168.1.42under "Network ports." - Select it.
- Modify your code, then click Upload as usual.
- The IDE will prompt for the OTA password. Enter
wavtron2026. - The firmware uploads over WiFi. The ESP32 reboots with the new code.
Critical Warning: Do Not Block the Loop
ArduinoOTA.handle() must run frequently. If your loop() has a delay(60000) for a once-per-minute sensor reading, OTA will time out and fail because the ESP32 is not listening for incoming uploads during the delay.
Fix: Use millis()-based timing instead of delay():
// BAD - blocks OTA for 60 seconds
void loop() {
ArduinoOTA.handle();
readSensor();
delay(60000);
}
// GOOD - non-blocking, OTA always responsive
void loop() {
ArduinoOTA.handle();
static unsigned long lastRead = 0;
if (millis() - lastRead >= 60000) {
lastRead = millis();
readSensor();
}
}
Limitations
- Your computer and the ESP32 must be on the same local network.
- mDNS discovery does not work across subnets or VLANs.
- Not suitable for devices deployed in the field on different networks.
Method 2: Web OTA (Browser-Based Upload)
This method runs a small web server on the ESP32. You open a browser, navigate to the ESP32's IP address, and upload a .bin file through a web form. The ElegantOTA library makes this trivially easy with a polished upload page and progress bar.
Best for: Devices on a known network where you want a user-friendly upload interface without needing the Arduino IDE.
Complete Code
Install the library first: in Arduino IDE, go to Sketch > Include Library > Manage Libraries, search for "ElegantOTA" by Ayush Sharma, and install it. Also install "ESPAsyncWebServer" and "AsyncTCP".
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ElegantOTA.h>
const char* ssid = "YourWiFiSSID";
const char* password = "YourWiFiPassword";
AsyncWebServer server(80);
// Track uptime to demonstrate the update worked
unsigned long bootTime;
void setup() {
Serial.begin(115200);
bootTime = millis();
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
// Root page — shows device info and link to update page
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
String html = "<!DOCTYPE html><html><head>";
html += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
html += "<style>body{font-family:sans-serif;max-width:600px;margin:40px auto;padding:0 20px;}";
html += "a{display:inline-block;margin-top:20px;padding:12px 24px;background:#1E3A5F;";
html += "color:white;text-decoration:none;border-radius:8px;}</style></head><body>";
html += "<h1>ESP32 Device Dashboard</h1>";
html += "<p><strong>Firmware Version:</strong> 1.0.0</p>";
html += "<p><strong>Uptime:</strong> " + String(millis() / 1000) + " seconds</p>";
html += "<p><strong>Free Heap:</strong> " + String(ESP.getFreeHeap()) + " bytes</p>";
html += "<p><strong>WiFi RSSI:</strong> " + String(WiFi.RSSI()) + " dBm</p>";
html += "<a href='/update'>Upload New Firmware</a>";
html += "</body></html>";
request->send(200, "text/html", html);
});
// ElegantOTA handles the /update route automatically
ElegantOTA.begin(&server);
// Optional: set credentials for the update page
ElegantOTA.setAuth("admin", "wavtron2026");
server.begin();
Serial.println("Web server started. Open http://" + WiFi.localIP().toString() + " in your browser.");
}
void loop() {
ElegantOTA.loop();
}
How to Use
- Flash via USB (or via Method 1 if you already have ArduinoOTA set up).
- Open a browser and navigate to
http://<ESP32_IP>/update. - Enter credentials if you set authentication (
admin/wavtron2026). - You will see the ElegantOTA page with a file picker and upload button.
- To create the
.binfile: in Arduino IDE, go to Sketch > Export Compiled Binary. The.binfile will be in your sketch folder. - Select the
.binfile and click Update. - A progress bar shows the upload status. The ESP32 reboots automatically when done.
Adding Version Display
Bump the version string in your code each time you compile so you can confirm the update took effect:
#define FIRMWARE_VERSION "1.2.0"
// Then use FIRMWARE_VERSION in your status page
html += "<p><strong>Firmware Version:</strong> " + String(FIRMWARE_VERSION) + "</p>";
Limitations
- You still need to manually open a browser and upload the file.
- Not practical for 50+ devices — you would need to visit each device's URL individually.
- Requires knowing the device's IP address.
Method 3: HTTP OTA (Automatic Server-Based Updates)
This is the production-grade approach. The ESP32 periodically checks a remote server for new firmware. If a newer version is available, it downloads and installs the update automatically. No human interaction required.
Best for: Production deployments, device fleets, unattended operation.
Server Setup
Host your firmware binary on any HTTP server. A simple setup with version checking:
Create a file called firmware.json on your server:
{
"version": "1.3.0",
"url": "https://firmware.yourdomain.com/esp32/firmware-1.3.0.bin",
"sha256": "a1b2c3d4e5f6..."
}
You can use any web host, an S3 bucket, GitHub Releases, or even a simple Nginx server. The ESP32 will fetch this JSON, compare versions, and download the binary if a newer version is available.
Complete Code
#include <WiFi.h>
#include <HTTPClient.h>
#include <Update.h>
#include <ArduinoJson.h>
const char* ssid = "YourWiFiSSID";
const char* password = "YourWiFiPassword";
// Current firmware version — bump this with every release
#define FIRMWARE_VERSION "1.0.0"
// URL to the version manifest on your server
const char* VERSION_URL = "https://firmware.yourdomain.com/esp32/firmware.json";
// How often to check for updates (in milliseconds)
const unsigned long UPDATE_CHECK_INTERVAL = 3600000; // 1 hour
unsigned long lastUpdateCheck = 0;
// Compare semantic version strings: returns true if remote > local
bool isNewerVersion(const char* local, const char* remote) {
int localMajor, localMinor, localPatch;
int remoteMajor, remoteMinor, remotePatch;
sscanf(local, "%d.%d.%d", &localMajor, &localMinor, &localPatch);
sscanf(remote, "%d.%d.%d", &remoteMajor, &remoteMinor, &remotePatch);
if (remoteMajor != localMajor) return remoteMajor > localMajor;
if (remoteMinor != localMinor) return remoteMinor > localMinor;
return remotePatch > localPatch;
}
void performOTAUpdate(const char* firmwareUrl) {
Serial.println("Starting OTA update from: " + String(firmwareUrl));
HTTPClient http;
http.begin(firmwareUrl);
http.setTimeout(30000); // 30 second timeout
int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
Serial.printf("Firmware download failed. HTTP code: %d\n", httpCode);
http.end();
return;
}
int contentLength = http.getSize();
if (contentLength <= 0) {
Serial.println("Invalid content length.");
http.end();
return;
}
Serial.printf("Firmware size: %d bytes\n", contentLength);
if (!Update.begin(contentLength)) {
Serial.println("Not enough space for OTA update.");
http.end();
return;
}
WiFiClient* stream = http.getStreamPtr();
size_t written = Update.writeStream(*stream);
if (written != contentLength) {
Serial.printf("Wrote only %d of %d bytes. Update failed.\n", written, contentLength);
Update.abort();
http.end();
return;
}
if (!Update.end()) {
Serial.printf("Update error: %s\n", Update.errorString());
http.end();
return;
}
http.end();
if (Update.isFinished()) {
Serial.println("OTA update successful. Rebooting...");
delay(1000);
ESP.restart();
}
}
void checkForUpdates() {
Serial.println("Checking for firmware updates...");
Serial.println("Current version: " + String(FIRMWARE_VERSION));
HTTPClient http;
http.begin(VERSION_URL);
http.setTimeout(10000);
int httpCode = http.GET();
if (httpCode != HTTP_CODE_OK) {
Serial.printf("Version check failed. HTTP code: %d\n", httpCode);
http.end();
return;
}
String payload = http.getString();
http.end();
// Parse JSON response
JsonDocument doc;
DeserializationError error = deserializeJson(doc, payload);
if (error) {
Serial.print("JSON parse error: ");
Serial.println(error.c_str());
return;
}
const char* remoteVersion = doc["version"];
const char* firmwareUrl = doc["url"];
Serial.println("Remote version: " + String(remoteVersion));
if (isNewerVersion(FIRMWARE_VERSION, remoteVersion)) {
Serial.println("New firmware available! Updating...");
performOTAUpdate(firmwareUrl);
} else {
Serial.println("Firmware is up to date.");
}
}
void setup() {
Serial.begin(115200);
Serial.println("Firmware version: " + String(FIRMWARE_VERSION));
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Connected. IP: ");
Serial.println(WiFi.localIP());
// Check for updates on boot
checkForUpdates();
}
void loop() {
// Periodic update check
if (millis() - lastUpdateCheck >= UPDATE_CHECK_INTERVAL) {
lastUpdateCheck = millis();
checkForUpdates();
}
// Your application code here
static unsigned long lastRead = 0;
if (millis() - lastRead >= 10000) {
lastRead = millis();
Serial.println("Sensor reading...");
}
}
Fleet Deployment Workflow
When you manage dozens or hundreds of ESP32 devices, the workflow looks like this:
- Compile your new firmware and export the
.binfile. - Upload the
.binto your firmware server (S3, VPS, CDN). - Update
firmware.jsonwith the new version number and URL. - Wait. Every device will check the manifest on its next update interval, see the new version, and self-update.
For staged rollouts, you can create separate manifests per device group:
firmware.yourdomain.com/esp32/beta/firmware.json -> points to v1.4.0-beta
firmware.yourdomain.com/esp32/stable/firmware.json -> points to v1.3.0
Flash beta devices with the beta URL and production devices with the stable URL. Roll a new release to beta first, validate for a week, then promote it to stable.
Security: Protecting Your OTA Pipeline
An unprotected OTA mechanism is a gaping security hole. Anyone who can reach your device over the network could push malicious firmware. Here is how to lock it down.
1. Use HTTPS
Always serve firmware over HTTPS. The ESP32 supports TLS with certificate verification:
#include <WiFiClientSecure.h>
WiFiClientSecure client;
// Option A: Pin a root CA certificate
const char* rootCA = \
"-----BEGIN CERTIFICATE-----\n"
"MIIDdzCCAl+gAwIBAgIEAgAAuTANBg...\n"
"-----END CERTIFICATE-----\n";
client.setCACert(rootCA);
// Option B (less secure, but workable for development): skip verification
// client.setInsecure();
2. Verify Firmware Integrity
Add a SHA-256 checksum to your version manifest and verify it after download:
#include <mbedtls/sha256.h>
// After downloading, calculate SHA-256 of the received data
// and compare with the expected hash from firmware.json
3. Password-Protect Upload Endpoints
For ArduinoOTA, always set a password:
ArduinoOTA.setPassword("your-strong-password");
For Web OTA (ElegantOTA), use authentication:
ElegantOTA.setAuth("admin", "your-strong-password");
4. Firmware Signing (Advanced)
ESP-IDF supports Secure Boot v2, which cryptographically signs firmware images. The bootloader verifies the signature before executing the firmware. This prevents any unsigned code from running on your device, even if an attacker manages to flash it. This requires ESP-IDF setup and is beyond Arduino IDE, but it is essential for commercial products.
Best Practices for Production OTA
Always Include OTA in Your First Flash
This bears repeating. If your first USB-flashed firmware does not include OTA code, you cannot add it later without physical access. Build OTA into your base firmware template from the start.
Use a Watchdog Timer to Recover from Bad Updates
If a new firmware has a bug that causes a crash loop, you need a way to recover. The ESP32 has a built-in rollback mechanism, but you need to explicitly confirm that the new firmware is working:
#include <esp_ota_ops.h>
void setup() {
// ... your setup code ...
// If we booted into a new OTA partition, run diagnostics
const esp_partition_t* running = esp_ota_get_running_partition();
esp_ota_img_states_t ota_state;
if (esp_ota_get_state_partition(running, &ota_state) == ESP_OK) {
if (ota_state == ESP_OTA_IMG_PENDING_VERIFY) {
// New firmware! Run basic health checks
bool healthy = true;
// Check 1: WiFi connects
if (WiFi.status() != WL_CONNECTED) healthy = false;
// Check 2: Critical peripherals respond
// if (!sensorInit()) healthy = false;
if (healthy) {
esp_ota_mark_app_valid_cancel_rollback();
Serial.println("New firmware verified and confirmed.");
} else {
Serial.println("New firmware failed health check. Rolling back...");
esp_ota_mark_app_invalid_rollback_and_reboot();
}
}
}
}
Report Firmware Version to Your Backend
Every device should report its current firmware version to your server. This lets you track which devices have been updated and which are still running old firmware:
void reportVersion() {
HTTPClient http;
http.begin("https://api.yourdomain.com/devices/report");
http.addHeader("Content-Type", "application/json");
String body = "{\"device_id\":\"" + WiFi.macAddress() +
"\",\"firmware_version\":\"" + FIRMWARE_VERSION +
"\",\"ip\":\"" + WiFi.localIP().toString() +
"\",\"rssi\":" + String(WiFi.RSSI()) + "}";
http.POST(body);
http.end();
}
Use Partition Schemes Wisely
The default Arduino partition scheme (default.csv) provides two 1.25 MB app partitions. If your firmware is small (under 640 KB), consider using the "Minimal SPIFFS" partition scheme, which gives you two 1.8 MB app partitions. If you need even more space, create a custom partition table.
In Arduino IDE: Tools > Partition Scheme > Minimal SPIFFS (1.9MB APP with OTA/190KB SPIFFS).
Troubleshooting Common Issues
| Problem | Cause | Solution |
|---|---|---|
| Arduino IDE does not show network port | mDNS not working, firewall blocking port 3232 | Check firewall rules. Ensure ArduinoOTA.begin() is called. Try restarting IDE. |
| Upload starts but fails at ~50% | WiFi signal too weak or unstable | Move closer to router. Use WiFi.setTxPower() to boost signal. Add retry logic. |
| "Not enough space" error | Firmware binary exceeds partition size | Use Minimal SPIFFS partition scheme. Reduce code size. Remove unused libraries. |
| Device crashes after OTA update | New firmware has a bug, or stack overflow | Implement rollback verification (see watchdog section above). Test locally first. |
| OTA works once, then stops | New firmware does not include OTA code | Always include OTA setup in every firmware version. Never ship without it. |
| ESP32 reboots during update | Power supply insufficient under WiFi + flash write load | Use a stable 5V/1A power supply. Add a 470uF capacitor across VIN and GND. |
| HTTPS connection fails | Incorrect or expired CA certificate | Update the root CA certificate in your code. For testing, use client.setInsecure(). |
| Memory fragmentation crashes | Large HTTP responses fragmenting heap | Use streaming downloads (writeStream) instead of loading the entire binary into memory. |
Choosing the Right Method
| Criteria | Arduino OTA | Web OTA | HTTP OTA (Server) |
|---|---|---|---|
| Setup complexity | Low | Low | Medium |
| Requires IDE | Yes | No | No |
| Requires browser | No | Yes | No |
| Remote network | No (LAN only) | Yes (if port forwarded) | Yes |
| Automated | No | No | Yes |
| Fleet management | No | No | Yes |
| Best for | Development | Field technicians | Production deployment |
For most projects, the ideal path is: start with Arduino OTA during development, add Web OTA for convenient field updates, and graduate to HTTP OTA when you deploy to production at scale.
Combining All Three Methods
There is no reason to choose just one. A robust firmware can include all three methods simultaneously:
#include <WiFi.h>
#include <ArduinoOTA.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ElegantOTA.h>
#include <HTTPClient.h>
#include <Update.h>
AsyncWebServer server(80);
void setup() {
// WiFi connection...
// Method 1: Arduino OTA for development
ArduinoOTA.setHostname("device-001");
ArduinoOTA.setPassword("dev-password");
ArduinoOTA.begin();
// Method 2: Web OTA for field technicians
ElegantOTA.begin(&server);
ElegantOTA.setAuth("admin", "field-password");
server.begin();
// Method 3: HTTP OTA check on boot
checkForUpdates();
}
void loop() {
ArduinoOTA.handle();
ElegantOTA.loop();
// Periodic server check
static unsigned long lastCheck = 0;
if (millis() - lastCheck >= 3600000) {
lastCheck = millis();
checkForUpdates();
}
}
This gives you maximum flexibility: automated updates in production, browser uploads when a technician visits the site, and IDE uploads when you are debugging on your bench.
Wrapping Up
OTA updates transform the ESP32 from a "flash once and forget" microcontroller into a maintainable, updatable platform that you can improve continuously without ever touching the hardware. The three methods covered here — Arduino OTA for development, Web OTA for browser-based uploads, and HTTP OTA for automated fleet management — cover every stage from prototyping to large-scale production.
The single most important takeaway: include OTA in your very first flash. It costs almost nothing in code size or complexity, and it saves you from the one scenario every IoT developer dreads — a deployed device running buggy firmware with no way to fix it remotely.
Start with the Arduino OTA example above, flash it to your ESP32 today, and you will never need to reach for that USB cable again.



