The Exact Error: 'Connection Refused' in ESP32 HTTPUpdate
When deploying Over-The-Air (OTA) updates to an ESP32 using the native HTTPUpdate library, few errors are as frustratingly opaque as the connection rejection. If your serial monitor is printing the exact string [HTTP-UPDATE] failed, error: connection refused (often accompanied by HTTPClient error code -1), your ESP32 is not failing to find the network. It is successfully reaching the target IP address, but the host server is actively rejecting the TCP connection.
In TCP/IP networking, a 'connection refused' error means the destination server replied with a TCP RST (Reset) packet. This is fundamentally different from a 'timeout' (Error -11), where the router drops the packets silently because the IP is unreachable or the host is offline. A refusal means the server is online, but no application is listening on the specific port you requested, or a local firewall explicitly blocked the handshake.
Update.write() phase will corrupt the bootloader and brick the module, requiring a manual serial re-flash.
Diagnostic Matrix: Why Your ESP32 OTA is Failing
Before rewriting your code, map your exact serial output to the table below. The HTTPClient library returns specific negative integers for network-layer failures, and standard HTTP status codes (like 404) for application-layer failures.
| Error Code / String | Root Cause | TCP/IP Behavior | Exact Fix |
|---|---|---|---|
-1 / connection refused |
Server bound to localhost (127.0.0.1) instead of LAN IP. | Host receives SYN on LAN IP, but service only listens on loopback. Host sends RST. | Bind server to 0.0.0.0 (e.g., python3 -m http.server 8080 --bind 0.0.0.0). |
-1 / connection refused |
Host OS Firewall (Windows Defender / UFW) blocking inbound port. | Firewall intercepts SYN packet and immediately replies with RST on behalf of the OS. | Add inbound allow rule for TCP port 8080 (or your chosen port) in the host firewall. |
-11 / read Timeout |
Server accepted connection but took too long to send HTTP headers. | TCP 3-way handshake succeeds. ESP32 waits for data, hits default 5000ms timeout, drops. | Increase httpUpdate.setLedPin(-1) and check server disk I/O. Use client.setTimeout(10000). |
404 / Not Found |
Connected successfully, but the .bin file path is incorrect. |
TCP and HTTP succeed. Application layer rejects the specific URI request. | Verify exact casing of the filename (Linux servers are case-sensitive) and URL path. |
-10 / SSL handshake failed |
Using HTTPS without providing a Root CA certificate or fingerprint. | TCP connects, but TLS negotiation fails because ESP32 cannot verify the server cert. | Use httpUpdate.setInsecure() for testing, or load the Let's Encrypt Root CA into WiFiClientSecure. |
Hardware & Pin Mapping for the OTA Build
To make debugging visible without staring at the serial monitor, this build maps external status LEDs to indicate OTA success or failure. We are targeting the standard ESP32-WROOM-32 DevKit V1 (30-pin variant). If you are using the 38-pin variant, the GPIO numbers remain identical, but physical pin positions on the board edges will differ.
Parts List:
- 1x ESP32-WROOM-32 DevKit V1 (30-pin, Type-C or Micro-USB)
- 1x 5mm Green LED (Success indicator)
- 1x 5mm Red LED (Failure indicator)
- 2x 220Ω or 330Ω through-hole resistors
- Host PC running Python 3.x (for the local OTA server)
| Component | ESP32 GPIO | Physical Pin (30-pin board) | Notes |
|---|---|---|---|
| Green LED (Anode) | GPIO 2 | Pin 24 (D2) | Also the onboard LED. 220Ω resistor in series. |
| Red LED (Anode) | GPIO 4 | Pin 26 (D4) | External breadboard LED. 220Ω resistor in series. |
| LED Cathodes | GND | Pin 1 or 15 | Common ground rail on breadboard. |
Complete Compilable OTA Code with Error Handling
The following C++ code is fully compilable in the Arduino IDE (ensure you have the Espressif ESP32 Core installed via Board Manager). It includes explicit pin definitions, WiFi reconnection logic, and granular error handling that prints the exact failure string to the serial monitor.
firmware.bin file, and run: python3 -m http.server 8080 --bind 0.0.0.0. This ensures the server binds to all network interfaces, preventing the most common cause of the 'connection refused' error.
#include <WiFi.h>
#include <HTTPUpdate.h>
// --- Network & Server Configuration ---
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* firmwareUrl = "http://192.168.1.100:8080/firmware.bin"; // Change to your host IP
// --- Pin Definitions ---
#define LED_SUCCESS 2 // Green LED (Also onboard LED)
#define LED_FAIL 4 // Red LED (External)
void setup() {
Serial.begin(115200);
pinMode(LED_SUCCESS, OUTPUT);
pinMode(LED_FAIL, OUTPUT);
// Brief startup blink
digitalWrite(LED_SUCCESS, HIGH);
delay(200);
digitalWrite(LED_SUCCESS, LOW);
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nConnected! IP: " + WiFi.localIP().toString());
// Trigger OTA check on boot
performOTAUpdate();
}
void loop() {
// In a real application, you would trigger OTA via MQTT,
// a physical button, or a deep-sleep timer.
delay(10000);
}
void performOTAUpdate() {
WiFiClient client;
HTTPUpdate httpUpdate;
// Configure timeouts to prevent infinite hangs
client.setTimeout(10000); // 10 seconds TCP timeout
httpUpdate.setLedPin(-1); // Disable internal LED handling, we do it manually
httpUpdate.rebootOnUpdate(false); // Manual reboot control for safety
Serial.println("Checking for OTA update...");
// The actual HTTP GET and Flash Write sequence
t_httpUpdate_return ret = httpUpdate.update(client, firmwareUrl);
switch (ret) {
case HTTP_UPDATE_FAILED: {
// Extract the exact error code and string
int errCode = httpUpdate.getLastError();
String errStr = httpUpdate.getLastErrorString();
Serial.printf("[OTA] FAILED! Error (%d): %s\n", errCode, errStr.c_str());
// Flash Red LED 3 times to indicate failure
for(int i=0; i<3; i++) {
digitalWrite(LED_FAIL, HIGH);
delay(250);
digitalWrite(LED_FAIL, LOW);
delay(250);
}
// Specific handling for connection refused
if (errCode == -1) {
Serial.println("[DEBUG] TCP RST received. Check host firewall and server bind address (0.0.0.0).");
}
break;
}
case HTTP_UPDATE_NO_UPDATES:
Serial.println("[OTA] Server returned 304 Not Modified. Firmware is current.");
digitalWrite(LED_SUCCESS, HIGH);
delay(1000);
digitalWrite(LED_SUCCESS, LOW);
break;
case HTTP_UPDATE_OK:
Serial.println("[OTA] SUCCESS! Rebooting into new firmware...");
digitalWrite(LED_SUCCESS, HIGH);
delay(1000); // Allow serial buffer to flush
ESP.restart();
break;
}
}
The First Three Things to Check When It Fails
If your serial monitor still outputs Error (-1): connection refused after verifying your URL string, execute these three diagnostic steps in order. Do not skip to firewall rules until you have verified the binding address.
- Verify the Server Binding Address (The 127.0.0.1 Trap):
Many local development servers (like Node.js Express, Flask, or basic Python scripts) default to binding to127.0.0.1(localhost). This means the server will only accept connections originating from the host machine itself. When the ESP32 sends a request from its IP (e.g., 192.168.1.50), the host OS rejects it. You must explicitly bind to0.0.0.0to accept LAN traffic. For Python, use--bind 0.0.0.0. For Node.js, useapp.listen(8080, '0.0.0.0'). - Cross-Device Port Verification:
Disconnect your smartphone from cellular data, connect it to the exact same WiFi network as the ESP32, and open a browser. Navigate tohttp://[YOUR_HOST_IP]:8080/firmware.bin. If the phone browser instantly says 'Connection Refused' or 'Site can't be reached', the issue is strictly on the host PC's network stack, not the ESP32 code. If the phone downloads the.binfile, your server is configured correctly, and the ESP32 is likely being blocked by a MAC filter or AP isolation setting on your router. - Inspect Host OS Firewall Rules:
Windows Defender Firewall and Linux UFW routinely block inbound traffic to non-standard ports (like 8080 or 8000) for unrecognized executables (likepython.exe).- Windows: Open 'Windows Defender Firewall with Advanced Security' > Inbound Rules > New Rule > Port > TCP > Specific local ports:
8080> Allow the connection. - Linux (UFW): Run
sudo ufw allow 8080/tcpin the terminal.
- Windows: Open 'Windows Defender Firewall with Advanced Security' > Inbound Rules > New Rule > Port > TCP > Specific local ports:
Extending and Simplifying Your OTA Pipeline
Once you have cleared the 'connection refused' hurdle on your local LAN, you will eventually need to push firmware to devices in the field. The Espressif OTA Architecture supports several scaling strategies.
Simplifying for Local LAN (UDP Broadcast):
If you are deploying 50 ESP32s in a single facility and don't want to manage a central HTTP server, simplify the pipeline using UDP. Have the ESP32s listen on a specific UDP multicast port. When you compile a new .bin, use a Python script to chunk the binary and broadcast it over UDP. This bypasses HTTP headers, TCP handshakes, and server binding issues entirely, though it requires implementing your own packet-acknowledgment logic to handle dropped UDP frames.
Extending to Cloud HTTPS (AWS S3 / GitHub Releases):
For production deployments, host your .bin files on AWS S3 or as GitHub Release assets. This requires switching from WiFiClient to WiFiClientSecure.
To avoid hardcoding expiring SSL certificates in your firmware, use the setInsecure() method during development, but for production, embed the root CA certificate (e.g., Amazon Root CA 1 or DigiCert) directly into the ESP32's SPIFFS/LittleFS partition. Furthermore, implement a 'version check' endpoint: have the ESP32 first download a tiny version.json file (under 500 bytes) to compare semantic version numbers before initiating the heavy multi-megabyte .bin download. This saves flash wear and reduces power consumption on battery-operated nodes.






