A battery-powered ESP32 that reports to the cloud can run for months on a single 18650 cell — but only if it sleeps correctly. Battery life on the ESP32 is almost entirely a Wi-Fi problem: the radio dominates the power budget, so the whole game is to stay asleep, wake briefly, connect fast, send, and sleep again. Get the wake short and rare and the idle current low, and the math works out to a season or more between charges. Get any of those wrong and the same hardware dies in days.
This guide is about the power side specifically: where the energy goes, the deep-sleep structure that makes it possible, connecting fast enough to matter, a real worked budget, and the dev-board traps that quietly wreck battery life. For the telemetry and control code itself, see Connect an ESP32 over HTTPS.
Where the power goes
Three states matter, and they’re orders of magnitude apart:
| State | Current (typical) | When |
|---|---|---|
| Deep sleep | ~10 µA (bare module) | between readings — almost all the time |
| Active + Wi-Fi | ~120-160 mA | the few seconds it’s connecting and sending |
| Peak TX burst | up to ~500 mA | brief spikes during transmit |
The lesson reads straight off the table: a few seconds at 150 mA costs roughly the same as hours of deep sleep. Lifetime is set by how short and how infrequent the active bursts are — not by the sleep time. That’s why “connect fast” is the highest-leverage optimization there is, and why a chatty board that stays awake “just in case” is the classic battery killer.
The deep-sleep shape
Deep sleep changes how you write the entire sketch. On wake the ESP32 powers the radio back up,
wipes RAM, and re-runs setup() from the top — loop() effectively never runs. Anything that
must survive a nap lives in RTC memory, declared with RTC_DATA_ATTR. So the structure is:
everything happens in setup(), ending with a call to sleep; loop() stays empty.
void setup() {
// wake → connect → read → send → poll → sleep
}
void loop() {} // intentionally empty for a deep-sleep design
Connect fast: cache the association
The single biggest win is skipping the Wi-Fi channel scan. On the first connect, cache the BSSID
and channel in RTC memory; on every wake after, hand them back to WiFi.begin() so it reconnects
directly. A static IP skips DHCP for another saved second. Both shave seconds off the radio-on
time, which — per the table above — is where the battery actually goes.
RTC_DATA_ATTR bool rtcValid = false;
RTC_DATA_ATTR uint8_t rtcBssid[6];
RTC_DATA_ATTR int32_t rtcChannel;
void fastConnect() {
WiFi.mode(WIFI_STA);
// Optional: a static IP skips DHCP entirely.
// WiFi.config(ip, gateway, subnet, dns);
if (rtcValid) WiFi.begin(WIFI_SSID, WIFI_PASS, rtcChannel, rtcBssid, true); // cached path
else WiFi.begin(WIFI_SSID, WIFI_PASS); // first boot
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 8000) delay(50); // bounded wait
if (WiFi.status() == WL_CONNECTED) {
memcpy(rtcBssid, WiFi.BSSID(), 6);
rtcChannel = WiFi.channel();
rtcValid = true;
} else {
rtcValid = false; // bad cache → force a full scan next time
}
}
The bounded wait matters as much as the cache: never block forever on WiFi.status(). A bad night
where the AP is unreachable should cost one capped attempt, not a flat battery.
The full sketch
Wake, connect, read the sensor, POST one reading, grab any queued command while the radio is up, and sleep on a timer. That’s the entire life of a battery node.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#define SLEEP_MINUTES 15
const char* HOST = "https://nodrix.you.workers.dev";
const char* TOKEN = "tok_your_project_token";
void setup() {
fastConnect();
if (WiFi.status() == WL_CONNECTED) {
float t = readTemp(); // your sensor
WiFiClientSecure client;
client.setInsecure(); // dev only — pin a CA in production
HTTPClient https;
https.begin(client, String(HOST) + "/v1/telemetry");
https.addHeader("Content-Type", "application/json");
https.addHeader("Authorization", String("Bearer ") + TOKEN);
https.POST("{\"metrics\":{\"temperature\":" + String(t, 1) + "}}"); // -> 204
https.end();
pollControl(); // apply queued commands while we're up
}
esp_sleep_enable_timer_wakeup((uint64_t)SLEEP_MINUTES * 60ULL * 1000000ULL);
esp_deep_sleep_start(); // execution ends here; wakes into setup()
}
void loop() {}
pollControl() is the downlink — fetch GET /v1/control, apply, ack. It’s worth doing every wake
since the radio is already on; the full version is in
Receive commands on an ESP32. A sleepy device can’t be pushed to
instantly, but commands queued in the cloud land on the next wake.
Do the math
Plug your numbers in; here’s a 15-minute cycle on a 2500 mAh cell:
- Per wake: ~120 mA × 3 s = 0.1 mAh.
- Wakes/day: 96 (every 15 min) → ~9.6 mAh/day from active time.
- Sleep/day: a bare module at 10 µA adds ~0.24 mAh/day; a typical dev board at 0.2-0.3 mA adds ~5-7 mAh/day.
- Total: ~10 mAh/day (bare) to ~16 mAh/day (dev board).
- Lifetime: 2500 mAh ÷ 10-16 ≈ 160-250 days — call it 5-8 months.
The spread is almost entirely sleep current, which is why the board you choose matters more than shaving another reading. Stretch the interval to 30-60 minutes and you cross a year; drop to one minute and you’re back to weeks.
Squeeze more
- Pick the right board. The biggest variable is idle current. A board with a USB-serial chip, a power LED, and a thirsty regulator can sit at hundreds of µA in “deep sleep.” For real battery builds use a low-sleep board or power the bare module.
- Give the sensor its warm-up. Many sensors need tens to hundreds of ms after power-up before a
valid reading — budget it rather than reading
nan. - Buffer offline. If a send fails, stamp the reading with your own
ts(NTP-synced) and send it next wake, so a dropped network doesn’t lose data. - Mind the strapping pins. Avoid GPIO 12 on the classic ESP32 (a strapping pin that can stop the board booting); safe wake/IO pins on the base chip include 4, 13, 14, 25, 26, and 27.
- Wake on more than a timer.
esp_sleep_enable_ext0/ext1_wakeuplets a reed switch, PIR, or button wake the board on an event instead of polling on a clock.
Notes
- It’s a Wi-Fi budget, not a sleep budget. Optimize the radio-on time first; everything else is rounding error.
- HTTPS fits deep sleep perfectly. There’s no session to keep alive — wake, POST, poll, sleep. No broker, no persistent socket.
- Runs on your account. Readings land in a nodrix instance on your own Cloudflare account, ready to chart, alert on, or read back through the API.