In this guide
Your ESP32 sensor node works perfectly on the desk. You seal it in a waterproof box with a battery, deploy it in the field, and two days later it is dead — while your back-of-the-envelope math said the battery should last six months. This is the single most common failure in student IoT projects, and it almost always comes down to one thing: the ESP32 never actually slept.
Deep sleep is the ESP32's answer to battery operation. Used correctly, it drops current consumption from over 100 mA to around 10 microamps — a factor of ten thousand. That is the difference between a node that dies over a weekend and one that runs for a year on a single charge. This guide explains the sleep modes, the wake sources, the RTC memory trick that makes deep sleep practical, how to do honest battery-life math, and the hardware traps that keep boards awake even when your code says sleep.
Why battery projects die early: the current budget
Everything in battery design is a current budget. Your battery holds a fixed amount of charge (mAh), and your circuit spends it continuously. The ESP32 is a power-hungry chip when fully awake — WiFi transmission pulls 160 to 240 mA depending on the mode and signal conditions, and even just sitting with WiFi on draws roughly 95 to 100 mA in receive. A typical 18650 cell holds about 2600 mAh. At 100 mA continuous draw, that cell lasts 26 hours. That is the desk scenario: always on, always connected.
Battery-powered sensing does not need always-on. A temperature node that reports every 10 minutes needs to be awake for perhaps 3 seconds out of every 600 — a duty cycle of 0.5%. If the other 597 seconds cost essentially nothing, the battery math transforms completely. Deep sleep is what makes those 597 seconds cost nothing.
The mental model that matters: minimize average current, not peak current. Peaks are handled by the battery and a decent capacitor. Lifetime is decided by the average, and the average is dominated by whatever state the chip spends most of its time in.
The four sleep modes, compared
The ESP32 offers four power modes. Only one of them is the right choice for battery sensor nodes, but you should understand all four:
| Mode | Typical current | What stays on | Wake latency | Use for |
|---|---|---|---|---|
| Active (WiFi on) | 95–240 mA | Everything | — | Mains-powered or brief awake windows |
| Modem sleep | 3–20 mA | CPU on, WiFi modem off between beacons | Fast | Staying associated to WiFi while saving some power |
| Light sleep | ~0.8 mA | CPU paused, RTC + memory retained | Fast | Short naps where you need instant resume with full RAM |
| Deep sleep | ~10 µA | RTC controller, RTC memory, ULP coprocessor | Slow (reboot) | Battery sensor nodes — the default choice |
(The ~10 µA figure is the datasheet deep-sleep current with RTC timer and RTC memory powered; real boards measure higher because of regulator and USB-UART leakage — more on that in the hardware section.)
Notice the tradeoff: deeper sleep means slower wakeup. Deep sleep is effectively a reboot — the main CPUs power down completely, RAM contents are lost, and the chip restarts from setup() on every wake. That sounds drastic, but for a sensor node it is exactly right: wake, measure, transmit, sleep. The RTC memory region survives, which gives you a small persistent scratchpad.
Note: there is also a hibernation mode (~5 µA) where only the RTC timer runs, but it loses RTC memory too. It is rarely worth the extra complexity for student projects — deep sleep is the practical floor.
Deep sleep: what actually stays on
When you call the deep sleep function, the ESP32 powers down the main digital core, both CPUs, WiFi, Bluetooth, and most peripherals. What remains powered:
- The RTC controller — runs the wakeup timer and monitors wake pins.
- RTC fast and slow memory (8 KB each) — survives deep sleep; this is where you keep counters and state.
- The ULP coprocessor (optional) — a tiny processor that can take sensor readings while the main CPUs sleep.
- RTC GPIOs — a subset of pins that can wake the chip.
Everything else is off. Your variables in normal RAM are gone. Any peripheral you configured is gone. The chip reboots on wake, which means your setup() runs again from scratch. Design your firmware around this cycle instead of fighting it: treat every wake as a fresh boot that checks why it woke up, does its job, and goes back to sleep.
Wake sources: how the chip wakes up
A sleeping chip is useless if nothing can wake it. The ESP32 supports several wake sources, and you can combine them:
| Wake source | Trigger | Notes |
|---|---|---|
| Timer | RTC timer expires | The workhorse — wake every N seconds/minutes |
| EXT0 | One RTC GPIO changes level | Single pin, configurable high or low level |
| EXT1 | Any of several RTC GPIOs change | Multiple pins, wake on any-high or all-low |
| Touch pad | Capacitive touch detected | Wake on touch — handy for user buttons |
| ULP coprocessor | ULP program decides | Sensor polling while main CPUs stay asleep |
Timer wake is what most battery projects use. Here is the minimal pattern:
#include "esp_sleep.h"
#define uS_TO_S_FACTOR 1000000ULL // microseconds to seconds
#define TIME_TO_SLEEP 600 // wake every 10 minutes
void setup() {
Serial.begin(115200);
// ... read sensor, send data ...
esp_sleep_enable_timer_wakeup(TIME_TO_SLEEP * uS_TO_S_FACTOR);
esp_deep_sleep_start(); // everything below never runs
}
void loop() {
// never reached in this pattern
}
External wake (EXT0) is for event-driven designs — a door sensor, a PIR motion trigger, a button press:
#define BUTTON_PIN_BITMASK 0x200000000 // GPIO33 in RTC domain
void setup() {
esp_sleep_enable_ext0_wakeup(GPIO_NUM_33, 1); // wake when pin goes HIGH
esp_deep_sleep_start();
}
Note: only RTC GPIOs work as deep-sleep wake pins (on the classic ESP32, GPIOs 0, 2, 4, 12–15, 25–27, 32–39). A non-RTC pin silently fails to wake the chip — one of the most common deep-sleep bugs. Check the pin map for your specific variant (ESP32-S3, ESP32-C3 have different RTC pin sets).
Finding out why you woke up
Since every wake is a reboot, your first job in setup() is asking the RTC controller what woke you:
void printWakeupReason() {
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
switch (cause) {
case ESP_SLEEP_WAKEUP_TIMER: Serial.println("Timer wake"); break;
case ESP_SLEEP_WAKEUP_EXT0: Serial.println("EXT0 pin wake"); break;
case ESP_SLEEP_WAKEUP_TOUCHPAD: Serial.println("Touch wake"); break;
default: Serial.println("First boot or reset"); break;
}
}
This lets one firmware handle multiple behaviors: timer wake means take a scheduled reading; pin wake means handle the event immediately. It also makes debugging sane — you can log wake reasons during development before sealing the box.
RTC memory: state that survives sleep
Normal variables die in deep sleep, but variables tagged RTC_DATA_ATTR live in RTC slow memory and survive:
RTC_DATA_ATTR int bootCount = 0;
void setup() {
Serial.begin(115200);
bootCount++;
Serial.print("Wake number: ");
Serial.println(bootCount);
// ... sensor work ...
esp_sleep_enable_timer_wakeup(600 * 1000000ULL);
esp_deep_sleep_start();
}
Use RTC memory for: wake counters, running averages, calibration offsets, the last transmitted value (so you can skip transmission if nothing changed — a huge battery saver). Keep it small — you have 8 KB. And remember it does not survive a full power loss, only deep sleep. For values that must survive battery swaps, use flash (Preferences library), but write sparingly — flash has limited write cycles.
A genuinely useful pattern: only transmit when the reading changed meaningfully since last wake. A temperature node in a stable room might skip 90% of its transmissions this way, and the radio is the hungriest part of the awake window.
Doing honest battery-life math
Here is the calculation that separates working deployments from dead ones. Worked example: a node wakes every 10 minutes, spends 3 seconds awake averaging 150 mA (sensor + WiFi + MQTT publish), and sleeps at 30 µA (10 µA chip + board leakage — measured, see hardware section).
Per hour there are 6 wake cycles:
- Awake charge: 6 × 3 s × 150 mA = 2700 mA·s = 0.75 mAh
- Sleep charge: ~3590 s × 0.03 mA ≈ 0.03 mAh
- Hourly total: ~0.78 mAh
On a 2600 mAh 18650: 2600 / 0.78 ≈ 3300 hours ≈ 137 days. That is the design target — treat it as an upper bound, then derate: batteries lose capacity in cold weather, self-discharge a few percent per month, and your awake window will be longer than you estimated the first time. A 0.6–0.7 derating factor is realistic engineering. Even derated, you are looking at roughly 3 months — versus about one day if the chip never slept.
The levers that matter most, in order:
- Shorten the awake window. Every second awake at 150 mA costs as much as ~80 minutes of deep sleep. Connect to WiFi fast (static IP, fixed channel), publish, disconnect, sleep. Do not
delay(). - Lengthen the sleep interval. Doubling the interval roughly halves average current. Ask whether you truly need 1-minute readings or whether 10 minutes answers the same question.
- Cut sleep current. This is a hardware problem (next section) — code cannot fix a leaky board.
- Skip redundant transmissions. If the value did not change, go back to sleep without powering the radio.
The hardware trap: your devkit is lying to you
Here is the painful truth most tutorials skip: a standard ESP32 devkit board typically draws several milliamps in deep sleep, not 10 µA. The chip sleeps fine — the board around it does not. The culprits:
- The voltage regulator. The AMS1117 on many cheap devkits has a quiescent current of around 5 mA even with no load. The chip's 10 µA is irrelevant next to it.
- The USB-to-UART bridge. Chips like the CP2102 draw current whenever powered, asleep or not.
- Power LEDs and pull-ups. Small but constant drains.
For a real battery deployment you have three options, in increasing order of effort:
- Measure first. Put a multimeter in series with the battery and read actual deep-sleep current on your board. If it reads 5 mA, no firmware trick will save you — fix the hardware.
- Use a low-power board designed for battery use (boards built around efficient buck regulators and load switches that fully cut peripheral power).
- Build a minimal custom board with just the ESP32 module, an efficient regulator with microamp quiescent current, and nothing else powered during sleep.
Also mind the brownout detector: as the battery voltage sags under the WiFi transmit peak, a weak supply can trigger brownout resets — the classic symptom is a node that works on USB but resets endlessly on battery. A large capacitor (several hundred µF) across the supply rails near the module absorbs the transmit peaks.
A complete deep-sleep sensor pattern
Putting it together — the skeleton most battery nodes converge on:
#include "esp_sleep.h"
#include <WiFi.h>
RTC_DATA_ATTR int wakeCount = 0;
RTC_DATA_ATTR float lastTemp = -999.0;
void setup() {
wakeCount++;
float t = readSensor(); // your sensor code
bool changed = abs(t - lastTemp) > 0.5;
if (changed || wakeCount % 6 == 0) { // send on change, or hourly heartbeat
wifiConnectFast(); // static IP, fixed channel
publishReading(t); // MQTT/HTTP send
WiFi.disconnect(true);
lastTemp = t;
}
esp_sleep_enable_timer_wakeup(600 * 1000000ULL);
esp_deep_sleep_start();
}
void loop() {}
The heartbeat (every 6th wake) matters: it tells your dashboard the node is alive even when readings are stable, so you can distinguish "nothing changed" from "node died" — pair this with an MQTT last-will message for clean offline detection.
Common mistakes checklist
- Leaving WiFi connected during sleep. Call
WiFi.disconnect()before sleeping, or the modem keeps drawing. - Using non-RTC pins for wake. Only RTC GPIOs wake from deep sleep; anything else silently never wakes.
- Forgetting the board leakage. 10 µA is the chip, not your devkit. Measure the whole board.
- Long
delay()in the awake window. Every wasted second costs ~80 minutes of sleep budget. - No brownout protection. Works on USB, resets on battery — add bulk capacitance.
- Floating wake pins. An unconnected EXT0/EXT1 pin picks up noise and wakes the chip constantly. Use pull-ups or pull-downs.
- Writing to flash every wake. Flash wears out and writes are slow; use RTC memory for per-wake state.
- Skipping the heartbeat. Without periodic check-ins you cannot tell a stable node from a dead one.
Where to go from here
- ESP32 low-power battery design guide — the hardware companion: regulator choice, battery chemistries, and solar top-up for nodes that must run indefinitely.
- MQTT protocol explained with practical examples — the publish pattern this skeleton uses, including retained state and last-will for offline detection.
- LoRa vs NB-IoT vs WiFi for IoT networks — if your node is far from WiFi, a different radio changes the entire power budget.
- FreeRTOS basics on ESP32 — for nodes too complex for the wake-and-sleep skeleton, tasks and timers give finer control.
- More power-conscious designs in the IoT & Embedded branch hub.