Designing for Low Power on ESP32: Firmware Patterns That Actually Save Current

ESP32 microcontroller with battery optimization code displayed on screen

Your ESP32 deep-sleeps at 10 µA. You celebrate. Then you measure average current over a full duty cycle and it’s 15 mA, because every 60 seconds the chip wakes up, spends 4 seconds reconnecting to Wi-Fi at 120 mA, reads a sensor in 50 ms, and goes back to sleep. The sleep current is a rounding error. The wake behavior is the entire problem.

Most ESP32 low power guides stop at esp_deep_sleep_start(). That’s the easy part. The hard part, the part that determines whether your product runs for two weeks or two years on a battery, is what your firmware does in those seconds between sleeps. This article covers five firmware patterns that attack that problem directly: disciplined wake architecture, peripheral isolation, Wi-Fi timing optimization, light sleep as an active-mode strategy, and ULP coprocessor offloading. Each pattern includes ESP-IDF v5.x code you can adapt immediately.

I’m assuming you know your way around ESP-IDF, FreeRTOS, and the ESP32 datasheet. If you’re looking for what deep sleep is, start with Espressif’s sleep modes documentation. This is about what comes after.

Pattern 1: Disciplined Wake → Work → Sleep Architecture

The most common anti-pattern in battery-powered ESP32 firmware is initializing everything at boot, then deciding what to do. Wi-Fi, Bluetooth, peripherals, NVS all brought up unconditionally, burning 100+ mA for hundreds of milliseconds before your application logic even runs.

The fix is a deterministic boot path that branches on wakeup cause and only initializes what the current cycle actually needs.

#include "esp_sleep.h"
#include "esp_wifi.h"

RTC_DATA_ATTR static int boot_count = 0;
RTC_DATA_ATTR static float sensor_accumulator[10];

void app_main(void)
{
    esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
    boot_count++;

    switch (cause) {
    case ESP_SLEEP_WAKEUP_TIMER:
        // Fast path: read sensor, store in RTC memory
        float reading = read_sensor_minimal_init();
        sensor_accumulator[boot_count % 10] = reading;

        if (boot_count % 10 == 0) {
            // Every 10th wake: init Wi-Fi, transmit batch, tear down
            wifi_transmit_batch(sensor_accumulator, 10);
        }
        break;

    default:
        // First boot / unexpected wake: full initialization
        full_system_init();
        break;
    }

    esp_sleep_enable_timer_wakeup(60 * 1000000ULL); // 60 seconds
    esp_deep_sleep_start();
}

The key insight: RTC_DATA_ATTR variables survive deep sleep in RTC slow memory. By accumulating sensor readings across multiple wake cycles and only connecting to Wi-Fi every Nth boot, you eliminate the single biggest current cost (radio-on time) from 90% of your duty cycles. Ten readings batched into one transmission cuts your Wi-Fi energy budget by roughly 10x.

Keep boot paths as short as possible. No printf calls (UART takes time), no unnecessary NVS reads, no component initialization you won’t use this cycle.

Pattern 2: Peripheral and GPIO Isolation Before Sleep

You’ve called esp_deep_sleep_start(), but your current meter shows 150 µA instead of the datasheet’s 10 µA. The usual culprits: floating GPIOs sourcing current into external circuits, RTC power domains left on, and internal pull-ups drawing through external resistors.

This is a firmware responsibility. Hardware won’t save you if your code doesn’t explicitly shut things down.

Power domain control lets you disable RTC subsystems you don’t need during sleep:

#include "esp_sleep.h"

// Disable RTC peripherals if no GPIO/touch wakeup source is configured
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_OFF);

// Disable RTC memory if ULP is unused
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_SLOW_MEM, ESP_PD_OPTION_OFF);
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_FAST_MEM, ESP_PD_OPTION_OFF);
Power Domain Control Options (ESP32-WROOM-32, typical)
+---------------------+------------------+------------------+
| Domain              | PD_OPTION_OFF    | Typical Savings  |
+---------------------+------------------+------------------+
| RTC_PERIPH          | If no wakeup src | ~2 µA            |
| RTC_SLOW_MEM        | If ULP unused    | ~0.5 µA          |
| RTC_FAST_MEM        | If ULP unused    | ~0.5 µA          |
| XTAL (light sleep)  | Auto             | ~1 µA            |
+---------------------+------------------+------------------+

GPIO isolation prevents current leakage through RTC-capable pins:

#include "driver/rtc_io.h"

void pre_sleep_gpio_isolation(void)
{
    // Isolate all RTC GPIOs not used for wakeup
    // RTC GPIOs: 0, 2, 4, 12-15, 25-27, 32-39 on ESP32
    const gpio_num_t isolate_pins[] = {
        GPIO_NUM_2, GPIO_NUM_4, GPIO_NUM_12, GPIO_NUM_13,
        GPIO_NUM_14, GPIO_NUM_15, GPIO_NUM_25, GPIO_NUM_26,
        GPIO_NUM_27
    };
    for (int i = 0; i < sizeof(isolate_pins)/sizeof(isolate_pins[0]); i++) {
        rtc_gpio_isolate(isolate_pins[i]);
    }
}

Build a pre-sleep checklist function and call it before every esp_deep_sleep_start():

  • Disable Wi-Fi/BT with esp_wifi_stop() / esp_bt_controller_disable()
  • Disable ADC with adc_power_release() (ESP-IDF manages this, but verify)
  • Isolate unused RTC GPIOs with rtc_gpio_isolate()
  • Disable internal pull-ups/pull-downs on pins connected to external circuits
  • Power down external sensors via GPIO-controlled MOSFET or LDO enable pin
  • Call esp_sleep_pd_config() for unused power domains

These individually save only microamps. Combined across all leakage paths, they’re often the difference between 10 µA and 150 µA in deep sleep, a 15x difference in sleep current that directly translates to battery life.

Pattern 3: Wi-Fi Timing Optimization

This is where the largest absolute savings live. On a typical ESP32 board, a naive Wi-Fi connect-transmit-disconnect cycle takes 3–5 seconds at 120–160 mA. An optimized one takes under 600 ms. Over thousands of daily cycles, that’s the difference between a coin cell lasting weeks and a LiPo lasting a year.

Static IP eliminates DHCP. DHCP negotiation adds 1–3 seconds of radio-on time. If your network allows it, hardcode the IP:

#include "esp_netif.h"

esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();
esp_netif_dhcpc_stop(sta_netif);

esp_netif_ip_info_t ip_info = {
    .ip.addr      = ESP_IP4TOADDR(192, 168, 1, 200),
    .gw.addr      = ESP_IP4TOADDR(192, 168, 1, 1),
    .netmask.addr = ESP_IP4TOADDR(255, 255, 255, 0),
};
esp_netif_set_ip_info(sta_netif, &ip_info);

Channel and BSSID pinning eliminates scanning. A full Wi-Fi scan can take 1–2 seconds across all channels. If your device always connects to the same AP, store the channel and BSSID:

RTC_DATA_ATTR static uint8_t saved_channel = 0;
RTC_DATA_ATTR static uint8_t saved_bssid[6] = {0};

wifi_config_t wifi_config = {
    .sta = {
        .ssid = "MyNetwork",
        .password = "MyPassword",
        .channel = saved_channel,       // 0 = scan all (first boot)
        .sort_method = WIFI_CONNECT_AP_BY_SIGNAL,
    },
};
if (saved_channel != 0) {
    memcpy(wifi_config.sta.bssid, saved_bssid, 6);
    wifi_config.sta.bssid_set = true;
}

After a successful connection, save the channel and BSSID back to RTC memory for the next cycle. On first boot or connection failure, fall back to a full scan.

DTIM alignment and listen interval matter for light sleep with a maintained Wi-Fi connection. Set maximum modem power save and tune the listen interval:

wifi_config.sta.listen_interval = 10; // Listen every 10th DTIM beacon
esp_wifi_set_ps(WIFI_PS_MAX_MODEM);

The combined impact is substantial:

Naive:     |--scan 2s--|--DHCP 2s--|--send 200ms--|  = ~4.2s @ 120mA avg
                                                      => 504 mJ (at 3.3V)

Optimized: |--connect 400ms--|--send 200ms--|         = ~600ms @ 120mA avg
                                                      => 72 mJ  (~86% reduction)

That’s not a marginal improvement. That’s a fundamentally different power budget.

Pattern 4: Light Sleep as an Active-Mode Strategy

Deep sleep gets all the attention, but light sleep is the right tool when you need wakeups more often than every ~10 seconds, want to maintain Wi-Fi association, or can’t afford the 300+ ms boot time of deep sleep.

The underused feature here is automatic light sleep with dynamic frequency scaling (DFS). With this enabled, ESP-IDF automatically drops the CPU into light sleep whenever all FreeRTOS tasks are idle. No explicit sleep calls needed.

#include "esp_pm.h"

esp_pm_config_t pm_config = {
    .max_freq_mhz = 240,
    .min_freq_mhz = 40,
    .light_sleep_enable = true,
};
ESP_ERROR_CHECK(esp_pm_configure(&pm_config));

Any idle period, whether waiting for a timer, a semaphore, or network data, automatically becomes a light sleep period at ~0.8 mA (typical, ESP32-WROOM-32) instead of ~20 mA active-idle. The transition takes about 1 ms, making it viable for sub-second idle periods that deep sleep can’t serve.

Should you use Deep Sleep, Light Sleep, or ULP?

                    ┌─────────────────────┐
                    │ Wake interval < 10s? │
                    └────┬───────────┬─────┘
                         │ YES       │ NO
                         ▼           ▼
               ┌─────────────┐  ┌──────────────────┐
               │ Need Wi-Fi  │  │ Sensor polling    │
               │ connection? │  │ between wakes?    │
               └──┬──────┬───┘  └──┬────────────┬───┘
                YES│     │NO      YES│           │NO
                  ▼      ▼          ▼            ▼
            ┌─────────┐┌──────┐┌─────────┐┌──────────┐
            │ Light   ││Light ││ ULP +   ││Deep Sleep│
            │ Sleep + ││Sleep ││ Deep    ││+ Timer   │
            │ Wi-Fi PS││      ││ Sleep   ││          │
            └─────────┘└──────┘└─────────┘└──────────┘

Combine automatic light sleep with WIFI_PS_MAX_MODEM and you get a device that maintains connectivity at 1–3 mA average instead of the 20+ mA you’d burn staying active-idle. For applications like BLE gateways or devices waiting for server commands, this is the right architecture.

Pattern 5: ULP Coprocessor for Sensor Polling

The classic battery-powered pattern (wake, read a sensor, decide if the reading is interesting, go back to sleep) wastes enormous energy when the answer is usually “not interesting.” The ESP32’s ULP coprocessor runs at ~5 µA and can execute this loop without ever waking the main CPUs.

This section covers the ULP FSM on the original ESP32. The ESP32-S2/S3 offer a ULP RISC-V core with a different programming model.

The architecture: your main CPU configures the ULP program with thresholds, then enters deep sleep. The ULP periodically reads the ADC, compares against the threshold, and only wakes the main CPU when action is required.

/* ULP assembly: read ADC, wake main CPU if above threshold */
    .text
    .global entry
entry:
    /* Read ADC1 channel 6 (GPIO34) */
    adc     r0, 0, 6+1       /* result in r0 (12-bit) */

    /* Compare against threshold (e.g., 2048 = ~1.65V) */
    move    r1, 2048
    sub     r2, r0, r1
    jump    below_threshold, ov  /* jump if r0 < r1 (overflow set) */

    /* Threshold exceeded — wake main CPU */
    wake
    halt

below_threshold:
    halt                       /* ULP will re-run after programmed delay */

Configure from the main CPU:

#include "esp32/ulp.h"
#include "ulp_main.h"

void setup_ulp_and_sleep(void)
{
    ulp_load_binary(0, ulp_main_bin_start,
        (ulp_main_bin_end - ulp_main_bin_start) / sizeof(uint32_t));

    ulp_set_wakeup_period(0, 100000); // 100ms polling interval
    ulp_run(&ulp_entry);

    esp_sleep_enable_ulp_wakeup();
    esp_deep_sleep_start();
}
Current comparison (typical, ESP32-WROOM-32):

Main CPU wake/sleep at 100ms interval: ~1.5 mA average
  (10 µA sleep + ~40 mA for ~3ms wake, amortized)

ULP polling at 100ms interval: ~5 µA continuous

Reduction: ~300x lower average current for the same sensing rate

For threshold-based sensing (tank level monitoring, motion detection, environmental alerts) the ULP transforms the power budget from “needs a LiPo” to “runs on a coin cell.”

Measuring What You’ve Actually Changed

None of these patterns matter if you don’t verify them. Two critical measurement pitfalls with ESP32 dev boards:

The USB-serial bridge lies. The CP2102 or CH340 on most dev boards draws 10–20 mA continuously, completely masking your sleep current. Always measure at the battery input with the USB disconnected, or use a bare module.

Average current is what matters. A scope capture of sleep current is satisfying but misleading. Use a tool that computes average current over full duty cycles: the Nordic PPK2, a µCurrent Gold, or an INA219 breakout with logging. Measure over at least 10 complete sleep-wake cycles.

ESP-IDF provides esp_pm_dump_locks() to identify what’s preventing automatic light sleep. If your measured light sleep current is higher than expected, a power management lock from Wi-Fi, UART, or a peripheral driver is almost always the cause.

Composing These Patterns Into Your Firmware

These five patterns aren’t independent optimizations. They compound. Here’s what the math looks like on a realistic sensor node (ESP32-WROOM-32, 60-second reporting interval, measured with PPK2):

Naive implementation: Wake → full init → scan → DHCP → send → sleep. Average: ~15 mA.

All five patterns applied: Conditional init + GPIO isolation + static IP/BSSID pinning + ULP for inter-cycle sensing + batched transmit every 10th cycle. Average: ~200 µA.

That’s a 75x improvement, the difference between a 2000 mAh battery lasting 5.5 days versus over a year.

Start with Pattern 3 (Wi-Fi timing). It has the highest single-pattern impact and requires the least architectural change. Then add Pattern 1 (boot path discipline) and Pattern 2 (peripheral isolation). Patterns 4 and 5 require more design investment but unlock the deepest savings for the right use cases.

Every microamp-hour you save in firmware is one you don’t have to solve with a bigger battery, a larger enclosure, or a more expensive BOM. That’s where low power design on the ESP32 stops being an optimization exercise and starts being a product differentiator.


Hubble Network connects ESP32-class devices directly to satellites via Bluetooth, eliminating the Wi-Fi and gateway infrastructure that drives most of your power budget. See how it works →