The ESP32 has become my default choice for battery-powered IoT prototypes and even for small production runs where cost, wireless connectivity, and power management matter more than raw processing power. Unlike many microcontrollers that force you to add an external radio module, the ESP32 integrates WiFi and Bluetooth on the same die as a capable dual-core processor, and it backs that up with genuinely usable low-power modes. In my experience, that integration is what makes or breaks a battery project — fewer chips means fewer quiescent currents to chase and fewer supply rail headaches. This article walks through how I use the ESP32 for reliable, low-power IoT nodes, from taming its WiFi stack to squeezing months of life out of a single LiPo cell with deep sleep.
Why the ESP32's Dual-Core Architecture Suits Connected Sensor Nodes
At its heart, the ESP32 is not just a WiFi chip. It is a system-on-chip with two Tensilica Xtensa LX6 cores running up to 240 MHz, 520 KB of SRAM, external flash, and a rich set of peripherals including ADC, DAC, touch sensors, I2C, SPI, UART, and PWM. For IoT work, the architecture matters because one core can handle the demanding WiFi/Bluetooth stack while the other runs your application code without jitter.
Most beginners flash an Arduino sketch and treat the ESP32 as a faster Arduino Uno with radio. That works, but you leave a lot on the table. Under the Arduino framework and even more so under ESP-IDF, FreeRTOS is running underneath. Understanding this helps you avoid blocking the radio. I have seen projects fail because a long delay() or a tight sensor polling loop starved the WiFi task and caused disconnections that looked like hardware faults.
The Ultra-Low-Power Coprocessor You Should Not Ignore
Beyond the two main cores, the ESP32 includes an Ultra-Low-Power (ULP) coprocessor. It can run while the main cores are in deep sleep, reading ADC values, checking GPIOs, or counting pulses, consuming only around 150 µA. In my experience, the ULP is perfect for slow, repetitive tasks like waking every few seconds to check a soil moisture sensor and only waking the main CPU if a threshold is crossed. It takes more setup than a simple timer wake, but for projects where events are rare, it is the difference between weeks and months of battery life.
ESP32 Variants and What to Choose for Battery Power
Not all ESP32 modules are equal for battery use. The classic ESP32-WROOM-32 is robust but not the most efficient. The newer ESP32-S2, S3, and C3 variants offer different trade-offs. The ESP32-S2 is single-core, WiFi-only, and has better deep sleep current. The ESP32-C3 uses a RISC-V core and is extremely cost-effective. For a general-purpose battery sensor where you need both WiFi and BLE, I still default to the original ESP32 or the ESP32-S3. If you are evaluating options more broadly, this guide on Microcontroller Selection: ARM Cortex-M, RISC-V and AVR Decision Criteria gives a solid framework for weighing architecture, ecosystem, and power.
Provisioning and Stabilizing ESP32 WiFi for Flaky IoT Networks
WiFi on the ESP32 is powerful but power-hungry. A transmit burst can draw 250-300 mA, and maintaining a connection requires periodic keep-alives. In a lab with a strong router, everything looks stable. In the field — a warehouse, a farm, an apartment with congested 2.4 GHz spectrum — connections drop, DHCP fails, and your node hangs if you do not plan for it.
I've found that three practices make WiFi reliable: fast reconnection logic, static IP or DHCP timeout handling, and aggressive power-down when you do not need the radio.
Robust WiFi Connection Patterns
Do not rely on a single WiFi.begin() call in setup(). Wrap your connection in a state machine with timeouts and retries, and always set a hostname and handle WiFi events. The ESP-IDF event system is more robust than the Arduino wrapper if you can use it, but even in Arduino, you can get far with this structure:
#include <WiFi.h>
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";
const int maxRetries = 5;
bool connectWiFi(int timeoutMs = 10000) {
WiFi.mode(WIFI_STA);
WiFi.setHostname("esp32-sensor-01");
WiFi.begin(ssid, password);
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < timeoutMs) {
delay(500);
Serial.print(".");
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\nConnected! IP: %s RSSI: %d dBm\n",
WiFi.localIP().toString().c_str(), WiFi.RSSI());
return true;
} else {
Serial.println("\nFailed to connect, going back to sleep");
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
return false;
}
}
void ensureWiFiWithBackoff() {
for (int attempt = 1; attempt <= maxRetries; attempt++) {
if (connectWiFi()) return;
int backoff = attempt * 2000;
Serial.printf("Retry %d/%d in %d ms\n", attempt, maxRetries, backoff);
delay(backoff);
}
}
This pattern prevents your node from staying awake forever hunting for a network. In battery projects, if WiFi fails after a few retries, the right answer is often to go back to deep sleep and try again in 10 minutes.
Reducing WiFi Power Without Losing Reliability
For periodic sensors, do not keep WiFi connected continuously. Connect, publish, disconnect. You can also enable modem sleep or use DTIM settings, but for deep sleep nodes that wake for only a few seconds, shutting the radio off entirely with WiFi.mode(WIFI_OFF) before sleeping saves the most power. I also set WiFi.setSleep(true) when I do need longer connected periods, which lets the radio duty-cycle during DTIM beacons. Expect about 15-20 mA average in modem sleep versus 80-100 mA with radio always on.
Choosing Between WiFi, Bluetooth Classic, and BLE on the Same Chip
One of the ESP32's biggest advantages is that it offers WiFi, Bluetooth Classic, and Bluetooth Low Energy (BLE) on one part. Beginners often default to WiFi for everything, but that is a fast path to dead batteries. Each radio has a clear role in IoT.
WiFi is for high throughput and direct internet access. Use it when you need to send data to MQTT, HTTP, or cloud endpoints without a gateway. BLE is for short-range, ultra-low-power communication with phones or gateways. Bluetooth Classic is rarely needed for modern sensor nodes — it is useful for audio or legacy serial port profile (SPP) devices, but it consumes more power than BLE.
When I Choose BLE Over WiFi
In my experience, BLE is the right choice when your sensor is near a gateway or a phone, and you want years of battery life. A BLE sensor can advertise for months on a coin cell because it transmits for milliseconds and sleeps the rest of the time. The ESP32 can act as a BLE peripheral (e.g., a temperature beacon) or as a central that collects data from other BLE sensors. If your project is primarily BLE-centric, you should also look at the approach in nRF52 BLE Sensor Node Design: From Schematic to Firmware, which shows how a dedicated BLE SoC can achieve even lower sleep currents than the ESP32.
A practical split I use often: ESP32 nodes in the field use BLE to report to a local Raspberry Pi as Industrial IoT Gateway: Modbus, MQTT and Edge Processing that aggregates and forwards via WiFi/Ethernet. That keeps the leaf nodes extremely low power while centralizing the power-hungry WiFi/MQTT uplink on a mains-powered gateway.
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#define SERVICE_UUID "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHARACTERISTIC_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
void setupBLE() {
BLEDevice::init("ESP32-BLE-Sensor");
BLEServer *pServer = BLEDevice::createServer();
BLEService *pService = pServer->createService(SERVICE_UUID);
BLECharacteristic *pCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_NOTIFY
);
pCharacteristic->setValue("Hello BLE");
pService->start();
BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(true);
pAdvertising->start();
Serial.println("BLE advertising started");
}
Designing for Microamps: ESP32 Deep Sleep, Light Sleep, and Wake Sources
This is where battery-powered ESP32 design lives or dies. The ESP32 has several sleep modes, and the difference between them is not subtle — it is milliamps versus microamps.
In active mode with WiFi on, expect 80-260 mA. In modem sleep, 15-25 mA. In light sleep, about 0.8-2 mA with RAM retained and CPU paused. In deep sleep, the main cores, WiFi, and most RAM are powered off, and current drops to 6-15 µA on a well-designed board. That deep sleep number assumes you have removed or gated the power LED and the USB-to-serial chip, which alone can leak 5-15 mA. I always measure sleep current with my actual development board before trusting datasheet numbers — many cheap DevKit boards never achieve true deep sleep current due to linear regulators and always-on peripherals.
| Sleep Mode | Typical Current (3.3V) | RAM Retention | Wake Sources Available | Wake-Up Time |
|---|---|---|---|---|
| Active (WiFi TX) | 160 - 260 mA | Yes | N/A | N/A |
| Modem Sleep | 15 - 25 mA | Yes | WiFi DTIM | Instant |
| Light Sleep | 0.8 - 2.5 mA | Yes | GPIO, Timer, UART, Touch | ~2 ms |
| Deep Sleep | 6 - 15 µA | No (8KB RTC only) | Timer, Touch, EXT0/EXT1 GPIO, ULP | ~30-50 ms + boot |
| Hibernation | ~2.5 µA | No | Timer, EXT0 only | ~50 ms + boot |
Configuring Timer and GPIO Wake-Up
For a periodic sensor, timer wake-up is the simplest and most reliable. The ESP32 uses its RTC controller to wake after a defined interval. You should also consider ext0/ext1 wake for user buttons or sensor interrupts — for example, waking instantly when a PIR motion sensor goes high. Note that in deep sleep, most RAM is lost. Save any state you need in RTC slow memory with RTC_DATA_ATTR or in flash.
#include "esp_sleep.h"
#include "esp_wifi.h"
RTC_DATA_ATTR int bootCount = 0;
void setup() {
Serial.begin(115200);
delay(500);
bootCount++;
Serial.printf("Boot count: %d\n", bootCount);
// Do your sensor reading and transmission here
// readSensorAndPublish();
// Prepare for deep sleep
Serial.println("Going to deep sleep for 10 minutes");
Serial.flush();
// Turn off WiFi and peripherals cleanly
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
esp_wifi_stop();
adc_power_off();
// Wake every 10 minutes (10 * 60 * 1e6 microseconds)
esp_sleep_enable_timer_wakeup(600ULL * 1000000ULL);
// Optional: wake on GPIO 33 high (e.g., motion sensor)
// esp_sleep_enable_ext0_wakeup(GPIO_NUM_33, 1);
esp_deep_sleep_start();
}
void loop() {
// Never reached
}
A few lessons I learned the hard way: always flush Serial before sleeping, call adc_power_off() if you used the ADC, and avoid delay() after initiating sleep. Also, remember that esp_deep_sleep_start() never returns — setup() runs fresh after each wake. Store counters or calibration data in RTC memory, but keep it small.
Light Sleep for Responsive, Low-Latency Nodes
Deep sleep is not always appropriate. If you need to maintain WiFi association or respond to UART data within milliseconds, light sleep is better. It retains full RAM and stack state. Under ESP-IDF and FreeRTOS, you can configure automatic light sleep with esp_pm_configure(), and the scheduler will enter light sleep whenever the idle task runs. See the FreeRTOS Documentation for how tickless idle interacts with sleep. For Arduino, call esp_light_sleep_start() manually. I use light sleep for mains-powered controllers that still want to save heat and power, not for multi-month battery nodes.
Battery Power Design: From Chemistry to Regulator Quiescent Current
Firmware is only half the power story. The hardware that feeds the ESP32 determines whether your 10 µA deep sleep actually translates to long battery life. Three factors dominate: battery chemistry, regulator efficiency, and parasitic board loads.
Battery Chemistry and Voltage Range
The ESP32 runs from 2.3V to 3.6V, nominally 3.3V. A single LiPo (3.0V - 4.2V) requires a low-dropout regulator (LDO) or buck converter to provide stable 3.3V. A pair of alkaline AAs (2.0V - 3.2V) needs a boost converter. My go-to for compact projects is a 18650 LiPo with a buck-boost converter like the TPS63001 or a high-quality LDO such as the RT9080 or XC6220 with < 2 µA quiescent current. Avoid the AMS1117 found on many DevKits — its quiescent current of 5-10 mA alone will drain a 2000 mAh battery in weeks, even if the ESP32 sleeps perfectly.
Always add proper battery protection, under-voltage lockout, and a charging circuit like the TP4056 or MCP73831 if you need USB charging. In my experience, the most reliable long-term deployment is a 18650 + TP4056 charger + 3.3V LDO with enable pin tied to ESP32 GPIO for full peripheral power gating.
The Parasitics That Kill Battery Life
Beyond the regulator, check these leaks: power LEDs (remove or cut the trace), USB-UART bridges (CP2102/CH340 draws milliamps even when idle — power it via a MOSFET or use a board with native USB like S2/S3 that can be gated), pull-up resistors on strapping pins, and sensors that never truly sleep. I power all external sensors from a GPIO or a load switch (e.g., AO3400 MOSFET) so they are physically disconnected during deep sleep. A BME280 left powered draws 0.1 µA in sleep, but a poorly chosen soil moisture sensor can draw 5 mA constantly and dominate your budget.
Calculate battery life honestly: Battery Life (hours) = Battery Capacity (mAh) / Average Current (mA). For a node that wakes for 4 seconds at 120 mA average (WiFi connect + MQTT publish), then sleeps for 10 minutes at 12 µA: Average = (4s * 120mA + 600s * 0.012mA) / 604s ≈ 0.806 mA. A 2500 mAh 18650 gives ~3100 hours, or about 129 days. Reduce wake time to 2 seconds and sleep to 15 minutes, and you exceed 200 days. Cutting wake time is often more valuable than shaving one more microamp of sleep current.
A Practical Build: Periodic WiFi Sensor Node with Deep Sleep and MQTT
To tie it together, here is the architecture I use for a field-deployed temperature/humidity sensor that reports every 15 minutes over WiFi to an MQTT broker. It wakes, connects, publishes, and sleeps. No OTA in deep sleep — for updates I add a boot button that extends wake time to 60 seconds to allow OTA.
Firmware Flow and MQTT Publishing
The flow is: increment boot count in RTC memory → power sensor rail → read I2C sensor → connect WiFi with timeout → connect MQTT → publish JSON → disconnect cleanly → schedule next wake. I keep the MQTT session short. For low-bandwidth, intermittent nodes, MQTT is far more efficient than HTTP because the handshake is smaller and keep-alive is not needed. The MQTT Specification is worth reading to understand QoS 0 vs QoS 1 trade-offs — I use QoS 1 for critical alerts and QoS 0 for regular telemetry to save bytes and time on air.
Putting Power Gating into Practice
Power the sensor from a GPIO. Drive it HIGH before reading, wait for its boot time (e.g., BME280 needs 2 ms, SHT30 needs 15 ms), read, then drive LOW before sleep. This ensures zero sensor drain in sleep. Also, store WiFi channel and BSSID from the last successful connection in RTC memory and pass them to WiFi.begin() to speed up reconnection from ~3 seconds to ~800 ms — a huge battery saving that is rarely documented well.
Finally, log your battery voltage. Use a voltage divider (e.g., 1MΩ / 1MΩ) to scale LiPo voltage to the ADC range, and only enable the divider via a GPIO during measurement to avoid constant leakage. Send that voltage with your telemetry so you can see battery degradation before the node dies. In my deployments, a simple moving average of voltage predicts failure 1-2 weeks early, which is enough time to schedule a battery swap.
Frequently Asked Questions
How long can an ESP32 realistically run on a battery with deep sleep?
With proper hardware, 3-6 months on a single 18650 (2500 mAh) is realistic for a sensor reporting every 10-15 minutes. If you optimize wake time below 2 seconds, use fast WiFi reconnect with stored channel/BSSID, and ensure your board's deep sleep current is under 15 µA, you can exceed a year at 30-minute intervals. Claims of multi-year life usually require BLE or a larger battery pack.
Should I use Arduino framework or ESP-IDF for battery projects?
Both can achieve low power, but ESP-IDF gives finer control over sleep modes, power domains, and FreeRTOS tickless idle. Arduino is excellent for prototyping and is perfectly fine if you properly call esp_deep_sleep_start(), power off peripherals, and disable WiFi before sleep. For production or ULP coprocessor use, I recommend moving to ESP-IDF. You can also use the Arduino core as an ESP-IDF component to get the best of both.
Why does my ESP32 DevKit drain the battery in days even with deep sleep code?
Most DevKits were not designed for battery power. Common culprits are the AMS1117 regulator (5 mA quiescent), the CP2102/CH340 USB bridge that stays powered, and the power LED. Measure current at the 3.3V rail, not USB. For battery testing, desolder the LED, use a board with a low-Iq regulator like the FireBeetle or TinyPICO, or build a minimal board with an RT9080 LDO.
Can the ESP32 maintain a WiFi connection while in deep sleep?
No. Deep sleep fully powers off the WiFi radio and most RAM. You must reconnect on every wake, which costs 0.8-3 seconds and significant energy. If you need persistent WiFi association with low latency, use light sleep or modem sleep instead, but expect milliamps rather than microamps. For battery nodes, the pattern of connect-publish-disconnect-sleep is more efficient despite the reconnect cost.