If you have ever deployed an ESP32-based sensor in a field, on a rooftop, or inside a water tank, you know the single biggest constraint is not processing power or memory. It is battery life. An ESP32 running full-tilt with WiFi active drains a typical 18650 cell in under 12 hours. That same ESP32, properly configured with deep sleep, can run for months or even years on the same battery.
This guide walks through everything you need to go from a power-hungry prototype to a field-ready, battery-optimized IoT device. We will cover the theory, write real Arduino code, do the maths on battery life, and discuss hardware modifications that make a measurable difference.
Why Battery Life Matters for IoT
Most interesting IoT use cases are inherently remote:
- Agricultural soil moisture sensors placed across a 5-acre farm in Karnataka
- Weather stations on a rooftop with no convenient power outlet
- Water level monitors inside overhead tanks or bore wells
- Cold chain temperature loggers inside delivery trucks
- Structural health monitors bolted to a bridge or building column
Running a power cable to each of these is impractical or impossible. Solar panels help, but they add cost, size, and a failure point. The most elegant solution is to make your device so power-efficient that a single lithium cell lasts an entire growing season, monsoon cycle, or deployment period.
The ESP32 was designed with this in mind. Its deep sleep current draw is 10 microamps -- roughly 24,000 times less than its active mode. The challenge is knowing how to use it properly.
ESP32 Power Modes Overview
The ESP32 has five distinct power modes. Here is what each one costs you:
| Mode | Typical Current | What Is Active | Use Case |
|---|---|---|---|
| Active (WiFi TX) | 160-260 mA | Everything: CPU, WiFi, Bluetooth, all peripherals | Sending data to server |
| Active (CPU only) | 20-68 mA | Dual-core CPU, peripherals, no radio | Local computation |
| Modem Sleep | ~20 mA | CPU active, WiFi/BT radio off | Processing sensor data locally |
| Light Sleep | ~0.8 mA | RTC, ULP, RAM retained, CPU paused | Waiting for frequent events |
| Deep Sleep | ~10 uA | RTC controller + RTC memory only | Long intervals between readings |
| Hibernation | ~5 uA | RTC timer only, no RTC memory | Absolute minimum, timer wake only |
The key insight: your device spends 99%+ of its time sleeping. If you read a sensor every 15 minutes, the active period might be 5-10 seconds. That means the sleep current dominates your power budget. Dropping from 0.8 mA (light sleep) to 10 uA (deep sleep) is a 80x improvement that translates directly to 80x longer battery life during those idle periods.
Understanding Deep Sleep
When the ESP32 enters deep sleep, almost everything shuts down:
What stays powered:
- RTC controller (the "alarm clock" that wakes the chip)
- RTC slow memory (8 KB you can use to store data across sleep cycles)
- RTC peripherals (touch pins, ULP coprocessor, external wake-up GPIOs)
What gets completely reset:
- Both CPU cores
- Main RAM (520 KB) -- all variables are lost
- WiFi and Bluetooth stacks
- All standard GPIO states
- SPI, I2C, UART peripherals
This means every wake-up from deep sleep is essentially a reboot. Your setup() function runs from the beginning. If you need to remember anything across sleep cycles (like a counter or accumulated sensor readings), you must store it in RTC memory.
RTC Memory: Your Persistent Scratch Pad
RTC memory survives deep sleep. You declare variables in it with the RTC_DATA_ATTR macro:
RTC_DATA_ATTR int bootCount = 0;
RTC_DATA_ATTR float lastTemperature = 0.0;
RTC_DATA_ATTR uint32_t sensorReadings[100];
RTC_DATA_ATTR int readingIndex = 0;
You have about 8 KB to work with. That is enough to store hundreds of sensor readings and batch-transmit them over WiFi, which is a key power optimization strategy we will cover below.
Wake-Up Sources
The ESP32 supports several ways to wake from deep sleep. You can combine multiple sources -- the chip wakes on whichever triggers first.
1. Timer Wake-Up
The simplest and most common. The RTC timer wakes the chip after a set number of microseconds.
esp_sleep_enable_timer_wakeup(15 * 60 * 1000000ULL); // 15 minutes
Use case: Periodic sensor readings -- temperature every 15 minutes, soil moisture every hour.
2. External Wake-Up (ext0)
A single RTC-capable GPIO triggers wake-up on HIGH or LOW.
esp_sleep_enable_ext0_wakeup(GPIO_NUM_33, 1); // Wake when GPIO33 goes HIGH
Use case: Button press, PIR motion sensor trigger, water level float switch.
3. External Wake-Up (ext1)
Multiple RTC GPIOs can trigger wake-up. You specify whether ANY one pin or ALL pins must be high.
uint64_t bitmask = (1ULL << GPIO_NUM_32) | (1ULL << GPIO_NUM_33);
esp_sleep_enable_ext1_wakeup(bitmask, ESP_EXT1_WAKEUP_ANY_HIGH);
Use case: Multiple sensors or buttons that should each independently wake the device.
4. Touch Pin Wake-Up
The ESP32's capacitive touch pins can wake from deep sleep.
touchSleepWakeUpEnable(T3, 40); // Touch pin T3, threshold 40
Use case: Touch-sensitive enclosures, proximity detection.
5. ULP Coprocessor Wake-Up
The Ultra-Low-Power coprocessor is a small processor that runs while the main cores sleep. It can read ADC values, monitor GPIOs, and wake the main CPU only when a condition is met.
Use case: Monitor a sensor continuously at ~150 uA and only wake the main CPU (and WiFi) when a threshold is crossed. Perfect for gas leak detectors or intrusion alarms.
Code: Timer Wake-Up with Sensor Reading
This is the most common pattern for IoT deployments. Read a sensor, send data over WiFi, go back to sleep.
#include <WiFi.h>
#include <HTTPClient.h>
#define SLEEP_DURATION_US (15 * 60 * 1000000ULL) // 15 minutes
#define WIFI_TIMEOUT_MS 10000
const char* WIFI_SSID = "YourNetwork";
const char* WIFI_PASSWORD = "YourPassword";
const char* API_ENDPOINT = "https://your-server.com/api/sensor";
RTC_DATA_ATTR int bootCount = 0;
void setup() {
Serial.begin(115200);
bootCount++;
Serial.printf("Boot #%d\n", bootCount);
// Read sensor (example: analog soil moisture on GPIO34)
float moisture = analogRead(34) / 4095.0 * 100.0;
float voltage = analogRead(35) / 4095.0 * 3.3 * 2; // Voltage divider
// Connect to WiFi
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
unsigned long startAttempt = millis();
while (WiFi.status() != WL_CONNECTED &&
millis() - startAttempt < WIFI_TIMEOUT_MS) {
delay(100);
}
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
http.begin(API_ENDPOINT);
http.addHeader("Content-Type", "application/json");
String payload = "{\"moisture\":" + String(moisture, 1) +
",\"battery\":" + String(voltage, 2) +
",\"boot\":" + String(bootCount) + "}";
int responseCode = http.POST(payload);
Serial.printf("HTTP response: %d\n", responseCode);
http.end();
} else {
Serial.println("WiFi connection failed, sleeping anyway");
}
// Disconnect WiFi explicitly before sleep
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
// Configure deep sleep
esp_sleep_enable_timer_wakeup(SLEEP_DURATION_US);
Serial.println("Entering deep sleep...");
Serial.flush();
esp_deep_sleep_start();
}
void loop() {
// Never reached -- deep sleep restarts from setup()
}
Key points in this code:
WiFi.disconnect(true)andWiFi.mode(WIFI_OFF)ensure the radio is fully off before sleep- The WiFi timeout prevents the device from hanging if the access point is down
Serial.flush()ensures debug output is sent before the chip powers downloop()is empty because deep sleep always restarts fromsetup()
Code: Batching Readings in RTC Memory
WiFi transmission is the most power-hungry operation. Instead of connecting every 15 minutes, batch 12 readings (3 hours worth) and transmit once:
#define READINGS_PER_BATCH 12
#define SLEEP_DURATION_US (15 * 60 * 1000000ULL)
RTC_DATA_ATTR int readingIndex = 0;
RTC_DATA_ATTR float moistureLog[READINGS_PER_BATCH];
RTC_DATA_ATTR float voltageLog[READINGS_PER_BATCH];
void setup() {
Serial.begin(115200);
// Read sensors
moistureLog[readingIndex] = analogRead(34) / 4095.0 * 100.0;
voltageLog[readingIndex] = analogRead(35) / 4095.0 * 3.3 * 2;
readingIndex++;
if (readingIndex >= READINGS_PER_BATCH) {
// Time to transmit
if (connectWiFi()) {
sendBatch();
}
readingIndex = 0; // Reset for next batch
} else {
Serial.printf("Reading %d/%d stored, sleeping...\n",
readingIndex, READINGS_PER_BATCH);
}
esp_sleep_enable_timer_wakeup(SLEEP_DURATION_US);
esp_deep_sleep_start();
}
This approach reduces WiFi connections from 96 per day to just 8, saving significant energy. Each WiFi connection + HTTP POST cycle costs about 3-5 seconds at 160 mA. Reducing 96 transmissions to 8 saves roughly 88 x 4s x 160mA = 56,320 mAs = 15.6 mAh per day.
Code: External Wake-Up (Button or Sensor Trigger)
For event-driven devices like a doorbell monitor or a water overflow alarm:
#define BUTTON_PIN GPIO_NUM_33
RTC_DATA_ATTR int triggerCount = 0;
void setup() {
Serial.begin(115200);
esp_sleep_wakeup_cause_t wakeReason = esp_sleep_get_wakeup_cause();
if (wakeReason == ESP_SLEEP_WAKEUP_EXT0) {
triggerCount++;
Serial.printf("External trigger #%d!\n", triggerCount);
// Debounce: wait for pin to settle
delay(50);
// Do something: send alert, sound buzzer, log event
if (connectWiFi()) {
sendAlert(triggerCount);
}
} else {
Serial.println("First boot or reset, going to sleep");
}
// Configure ext0 wake-up: wake when pin goes HIGH
esp_sleep_enable_ext0_wakeup(BUTTON_PIN, 1);
// Optional: also add a timer to send a daily heartbeat
esp_sleep_enable_timer_wakeup(24ULL * 60 * 60 * 1000000ULL); // 24 hours
esp_deep_sleep_start();
}
This device sleeps indefinitely (drawing ~10 uA) until the sensor triggers or 24 hours elapse for a heartbeat check-in. A 3000 mAh battery would theoretically last over 30 years if it only sleeps -- in practice, self-discharge limits lithium cells to about 5-10 years.
Measuring Actual Current Draw
Theory is nice, but you must measure your actual current draw. Deep sleep current on a bare ESP32 module is ~10 uA, but a DevKit board with power LED and USB-UART chip can draw 5-10 mA in deep sleep.
How to Measure Microamp Currents
Your standard multimeter's uA range is what you need:
- Break the power line. Disconnect the positive battery wire.
- Insert the multimeter in series. Positive probe to battery positive, negative probe (in the uA/mA jack) to the board's VIN.
- Set to uA range (often 200 uA or 2000 uA).
- Read the steady-state value after the board enters deep sleep.
Warning: Never use the uA range while the board is active (drawing 160+ mA). You will blow the multimeter's uA fuse. Start on the mA range, let the board sleep, then switch to uA.
Expected Measurements
| Configuration | Deep Sleep Current |
|---|---|
| ESP32-WROOM bare module | ~10 uA |
| ESP32 DevKit v1 (stock) | 5-10 mA |
| DevKit with power LED removed | 1-2 mA |
| DevKit with LED + regulator bypassed | ~30-50 uA |
| Custom PCB with AP2112 LDO | ~15-25 uA |
| Custom PCB with MCP1700 LDO | ~11-13 uA |
The DevKit's AMS1117 voltage regulator alone consumes 5-10 mA quiescent current. For any serious battery project, you must either bypass it or use a different board.
Battery Life Calculations
Let us do the maths for a real deployment. We will use the power budget spreadsheet approach -- listing every operating state, its current draw, and its duration per cycle.
Scenario: Soil Moisture Sensor, 15-Minute Interval
Hardware: ESP32 bare module + MCP1700 LDO + BME280 sensor + 18650 battery (3000 mAh)
| Phase | Duration | Current | Charge Used |
|---|---|---|---|
| Wake + sensor read | 100 ms | 40 mA | 0.0011 mAh |
| WiFi connect | 3,000 ms | 160 mA | 0.1333 mAh |
| HTTP POST + response | 1,500 ms | 160 mA | 0.0667 mAh |
| WiFi disconnect + prep | 100 ms | 40 mA | 0.0011 mAh |
| Deep sleep | 895,300 ms | 0.012 mA | 0.00299 mAh |
| Total per cycle | 900,000 ms (15 min) | 0.2052 mAh |
Daily consumption: 0.2052 mAh x 96 cycles = 19.7 mAh/day
Battery life: 3000 mAh / 19.7 mAh = 152 days (about 5 months)
With Batching (12 readings per transmission)
| Cycle Type | Cycles/Day | Charge Each | Daily Total |
|---|---|---|---|
| Read-only (no WiFi) | 88 | 0.0044 mAh | 0.387 mAh |
| Read + transmit batch | 8 | 0.2052 mAh | 1.642 mAh |
| Total | 96 | 2.029 mAh/day |
Battery life: 3000 mAh / 2.029 mAh = 1,479 days (over 4 years)
Batching gives you nearly a 10x improvement in battery life. In practice, lithium cell self-discharge (~2-3% per year) would limit you to about 3 years, which is still remarkable.
Quick Reference: Battery Life at Various Sleep Currents
Assuming a 3000 mAh 18650, transmitting once per hour (24 WiFi sessions/day at ~0.2 mAh each = 4.8 mAh/day WiFi cost):
| Deep Sleep Current | Sleep Cost/Day | Total/Day | Battery Life |
|---|---|---|---|
| 10 mA (stock DevKit) | 240 mAh | 244.8 mAh | 12 days |
| 1 mA (LED removed) | 24 mAh | 28.8 mAh | 104 days |
| 50 uA (regulator bypassed) | 1.2 mAh | 6.0 mAh | 500 days |
| 10 uA (custom PCB) | 0.24 mAh | 5.04 mAh | 595 days |
The message is clear: the stock DevKit board is the enemy of battery life. Either modify it or design a minimal custom board.
Hardware Tips for Minimum Power Draw
1. Remove the Power LED
The red power LED on most DevKit boards draws 2-5 mA constantly. Desolder it or cut the trace. This is the single easiest hardware modification.
2. Use a Low-Quiescent-Current Regulator
The AMS1117 on most DevKits has a quiescent current of 5-10 mA. Replace it (or bypass it) with:
| Regulator | Quiescent Current | Dropout | Notes |
|---|---|---|---|
| MCP1700 | 1.6 uA | 178 mV | Excellent, max 250 mA |
| AP2112 | 55 uA | 250 mV | Good, max 600 mA |
| HT7333 | 2.5 uA | 100 mV | Very low Iq, max 250 mA |
| ME6211 | 40 uA | 100 mV | Common in ESP32 boards |
If your battery voltage is 3.7V nominal and your ESP32 runs at 3.3V, these LDOs work perfectly. For the lowest possible current, the MCP1700 at 1.6 uA quiescent is hard to beat.
3. Choose the Right Battery
| Battery | Capacity | Weight | Notes |
|---|---|---|---|
| 18650 Li-ion | 2500-3500 mAh | 45g | Best energy density, needs holder |
| LiPo pouch 1S | 500-6000 mAh | varies | Flat form factor, needs protection circuit |
| CR123A (primary) | 1500 mAh | 17g | Non-rechargeable, good for deploy-and-forget |
| 2x AA Lithium | 3000 mAh | 30g | 3.0V direct to ESP32, no regulator needed! |
Pro tip: Two AA lithium primary cells (Energizer Ultimate Lithium) in series give you 3.0V, which is within the ESP32's operating range (2.3V-3.6V). You can skip the voltage regulator entirely, eliminating its quiescent current. These cells also work in extreme temperatures (-40 to 60 degrees C), making them ideal for outdoor deployments across Indian seasons.
4. Power Down External Peripherals
Use a GPIO pin to control power to sensors via a MOSFET:
#define SENSOR_POWER_PIN 25
void setup() {
pinMode(SENSOR_POWER_PIN, OUTPUT);
digitalWrite(SENSOR_POWER_PIN, HIGH); // Power on sensor
delay(100); // Let sensor stabilize
float reading = readSensor();
digitalWrite(SENSOR_POWER_PIN, LOW); // Power off sensor
// ... transmit, then sleep
}
Many sensors draw 1-20 mA continuously. A BME280 draws 3.6 uA in sleep mode, which is acceptable, but a soil moisture resistive sensor might draw several mA if left powered.
Combining Deep Sleep with LoRa
WiFi is power-hungry: 160 mA for 3-5 seconds per transmission. LoRa (Long Range radio) transmits at 40-120 mA for under 100 ms, with a range of 2-15 km line-of-sight.
For a farm monitoring network or a distributed sensor mesh, the combination of ESP32 deep sleep and LoRa is exceptionally powerful:
#include <LoRa.h>
#define LORA_SS 5
#define LORA_RST 14
#define LORA_DIO0 26
void setup() {
// Initialize LoRa
LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);
if (!LoRa.begin(433E6)) { // 433 MHz band (license-free in India)
// Handle error
}
LoRa.setTxPower(14); // 14 dBm
LoRa.setSpreadingFactor(7); // SF7 for shortest airtime
// Read sensor
float moisture = readMoisture();
float temperature = readTemperature();
// Build compact packet (binary, not JSON)
uint8_t packet[8];
packet[0] = 0x01; // Device ID
int16_t tempInt = (int16_t)(temperature * 100);
uint16_t moistInt = (uint16_t)(moisture * 100);
memcpy(&packet[1], &tempInt, 2);
memcpy(&packet[3], &moistInt, 2);
// Transmit -- takes ~50ms at SF7
LoRa.beginPacket();
LoRa.write(packet, 5);
LoRa.endPacket(true); // true = async
delay(100); // Wait for TX to complete
LoRa.sleep(); // Put LoRa module in sleep mode (~1uA)
// Deep sleep for 15 minutes
esp_sleep_enable_timer_wakeup(15 * 60 * 1000000ULL);
esp_deep_sleep_start();
}
LoRa vs WiFi Power Budget
| WiFi | LoRa (SF7) | |
|---|---|---|
| TX current | 160 mA | 80 mA |
| TX duration | 3-5 sec | 50-100 ms |
| Charge per TX | 0.178 mAh | 0.002 mAh |
| Daily (96 TX) | 17.1 mAh | 0.19 mAh |
| Battery life (3000 mAh) | ~170 days | ~4300 days |
LoRa reduces transmission energy by roughly 90x. Combined with deep sleep and a proper low-Iq regulator, you can genuinely achieve multi-year battery life from a single 18650 cell.
At the receiving end, a LoRa gateway (another ESP32 with LoRa module, plugged into mains power) collects data from all your field sensors and forwards it to your server over WiFi or Ethernet.
Common Pitfalls
1. WiFi Reconnection Time
WiFi association + DHCP takes 2-6 seconds. This is the largest energy cost per cycle. Reduce it by:
- Using a static IP instead of DHCP (saves 1-2 seconds)
- Storing the WiFi channel and BSSID in RTC memory and using
WiFi.begin(ssid, password, channel, bssid)for fast reconnect (~1 second)
RTC_DATA_ATTR int savedChannel = 0;
RTC_DATA_ATTR uint8_t savedBSSID[6] = {0};
void connectWiFiFast() {
WiFi.mode(WIFI_STA);
if (savedChannel != 0) {
WiFi.begin(SSID, PASSWORD, savedChannel, savedBSSID, true);
} else {
WiFi.begin(SSID, PASSWORD);
}
// After successful connection, save for next time
savedChannel = WiFi.channel();
memcpy(savedBSSID, WiFi.BSSID(), 6);
}
This fast-reconnect trick can cut WiFi connection time from 4 seconds to under 1 second.
2. Flash Writes During Sleep Cycles
Every call to Preferences, SPIFFS.write, or EEPROM.write wears the flash memory. NOR flash has a typical endurance of 100,000 write cycles per sector. If you write every 15 minutes, you will hit 100,000 cycles in about 2.8 years. Use RTC memory for frequently changing data and only write to flash for configuration that rarely changes.
3. Brown-Out Detection
When the battery voltage drops below ~2.5V, the ESP32's brown-out detector triggers a reset. This can cause a boot loop that drains the remaining battery rapidly. Handle it in your code:
void setup() {
float battVoltage = readBatteryVoltage();
if (battVoltage < 3.2) {
// Battery critically low -- sleep for 24 hours
// to preserve remaining charge for one final alert
if (battVoltage < 2.8) {
esp_sleep_enable_timer_wakeup(24ULL * 60 * 60 * 1000000ULL);
esp_deep_sleep_start();
}
// Send low-battery alert
sendLowBatteryAlert(battVoltage);
}
}
4. Forgetting to Sleep LoRa/Sensor Modules
External modules like the SX1276 (LoRa), BME280, or SSD1306 (OLED) have their own sleep modes. If you do not explicitly put them to sleep before the ESP32 sleeps, they continue drawing milliamps. Always call LoRa.sleep(), set the BME280 to forced mode, and turn off OLED displays.
5. GPIO State During Sleep
Standard GPIOs lose their state during deep sleep. If you are driving a MOSFET to power a sensor, it will float during sleep and may partially turn on. Use gpio_hold_en() to lock a pin's state, or use RTC GPIOs which can maintain state.
gpio_hold_en(GPIO_NUM_25); // Hold this pin's state during sleep
gpio_deep_sleep_hold_en(); // Enable hold during deep sleep
esp_deep_sleep_start();
Power Budget Spreadsheet Approach
For any serious deployment, create a spreadsheet before you build:
| Phase | Duration (ms) | Current (mA) | Charge (mAh) |
|---|---|---|---|
| Boot + sensor init | 150 | 40 | 0.00167 |
| Sensor reading | 50 | 45 | 0.000625 |
| WiFi connect (fast) | 1000 | 160 | 0.0444 |
| Data TX + ACK | 500 | 160 | 0.0222 |
| WiFi off + prep | 50 | 40 | 0.000556 |
| Deep sleep | 898,250 | 0.012 | 0.00299 |
| Per cycle total | 900,000 | 0.0719 mAh |
Daily: 0.0719 x 96 = 6.9 mAh/day
Battery life (3000 mAh 18650): 3000 / 6.9 = 434 days (~14 months)
Adjust the numbers with your actual measurements. The spreadsheet makes it easy to see where the energy is going and where to optimize next. In this example, WiFi connect dominates -- a clear signal to either batch readings or switch to LoRa.
Summary: The Path to Multi-Month Battery Life
- Start with deep sleep. Timer wake-up is the simplest and most common pattern.
- Batch your transmissions. Every WiFi session you eliminate saves ~0.2 mAh.
- Measure your actual current. Stock DevKit boards waste 5-10 mA in sleep.
- Fix the hardware. Remove the power LED, bypass or replace the AMS1117 regulator.
- Use fast WiFi reconnect. Cache BSSID and channel in RTC memory.
- Consider LoRa for remote sensor networks. 90x less energy per transmission.
- Build a power budget. Know exactly where every milliamp-hour goes.
- Handle low battery. Detect brown-out conditions and fail gracefully.
The ESP32's deep sleep capability transforms it from a bench prototype into a deployable field device. With the right techniques, a single 18650 cell can power your sensor through an entire monsoon season and beyond.
For your next battery-powered project, pick up an ESP32 DevKit, an 18650 battery holder, and the sensors you need from Wavtron. If you are building a remote sensor network, check out our LoRa modules (SX1276/SX1278) -- they pair perfectly with the deep sleep workflow described here. Questions about your specific power budget? Reach out to us on WhatsApp and we will help you spec the right components.



