ESP32 Deep Sleep for Battery-Powered Projects

Learn to run an ESP32 for months on a battery: the four sleep modes, timer and GPIO wake sources, RTC memory for state across reboots, honest battery-life math with a worked example, and the devkit hardware traps that silently ruin deep sleep.

Written by Projectech11 min readPublished
For B.E./B.Tech Electronics, E&TC and IoT students building battery-powered ESP32 sensor nodes for field or final-year projects Topics: ESP32, Arduino, Deep Sleep, RTC, Li-ion Batteries
Illustration of an ESP32 module with a battery beside it, a clock symbol for timed wakeups, and a current meter showing microamps during deep sleep.
Illustration generated for this guide.
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:

  1. 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().
  2. 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.
  3. Cut sleep current. This is a hardware problem (next section) — code cannot fix a leaky board.
  4. 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:

  1. 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.
  2. Use a low-power board designed for battery use (boards built around efficient buck regulators and load switches that fully cut peripheral power).
  3. 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

More project guides

More in IoT & Embedded