Battery complaints are the worst class of bug report. They arrive as a one-star review saying “drains my battery”, there is no stack trace, you cannot reproduce it, and the user has already uninstalled. Meanwhile the app behaves impeccably on your desk, plugged in.
The good news is that battery drain has a small number of causes and both platforms ship tools that will name yours. The bad news is that almost nobody uses them until the reviews arrive.
Why your device tells you nothing
A phone on a charger, on wifi, with the screen on and the debugger attached is in roughly the opposite state to the one where drain happens. Radios are already awake, the CPU is not being throttled, and the OS suspends none of your background work because it can see you are actively developing.
Drain happens when the phone is in a pocket, on a weak cellular signal, with the screen off, and the OS is trying to keep everything asleep. Your app waking the device repeatedly in that state is what costs battery — not the CPU work it does while awake.
That is the mental model worth carrying: on mobile, wakeups cost more than computation. Radios and the application processor consume power in the transition to an active state and then linger there. Ten small network calls spread across ten minutes cost far more than one batched call carrying the same data, even though the bytes are identical.
Measuring it on Android
Battery Historian remains the tool, and the workflow is a battery-stats reset followed by a period of realistic use.
# Reset the counters, then unplug and use the device normally for a while
adb shell dumpsys batterystats --reset
# ... unplug, use the app, lock the screen, wait 30+ minutes ...
adb shell dumpsys batterystats > stats.txt
adb bugreport bugreport.zip # feed this into Battery Historian
# Quick view without the UI: your app's wakelocks
adb shell dumpsys batterystats | grep -A20 "com.example.app"
The unplugging matters. Battery stats only accumulate on battery, so a report gathered over USB is empty of the very data you want.
What to look for, in order: partial wakelocks held for long durations, alarm counts far higher than they should be, and JobScheduler jobs running much more often than intended. Each maps to a specific mistake.
Measuring it on iOS
Instruments has an Energy Log template, and for field data the more valuable route is the Xcode Organizer’s Energy reports, which aggregate real usage from real users. That is the closest thing either platform offers to production battery telemetry.
Also enable the Energy Efficiency diagnostics via the device settings sysdiagnose profile when chasing a specific complaint. The signal to watch is the same as Android’s: how often you wake the device, how long you hold it awake, and how much you use the radios.
One iOS-specific note worth knowing: because the system rations background execution based on user engagement, an app with a battery problem in the foreground is a different investigation from one with a background problem. The background side is constrained enough that it is often not your drain source — a point that follows from how little iOS actually grants, covered in our background work guide.
Cause 1: Wakelocks you forgot to release
The classic Android bug. A partial wakelock keeps the CPU running with the screen off, and a wakelock acquired on a path that can throw — or that returns early — is never released. The device then cannot sleep at all, and the battery falls off a cliff.
// Broken: an exception skips release() entirely
wakeLock.acquire()
doWork() // throws -> device never sleeps again
wakeLock.release()
// Correct: a timeout AND a finally block
wakeLock.acquire(30_000L) // hard ceiling, always set one
try {
doWork()
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
Always pass a timeout to acquire(). It converts a permanent drain into a bounded one when the release path fails, and the release path will eventually fail.
Better still, avoid manual wakelocks. WorkManager acquires and releases them correctly on your behalf, which removes the entire failure mode — one of the stronger arguments for using it rather than hand-rolling background execution.
Cause 2: Polling instead of being told
An app checking the server every thirty seconds for updates that arrive twice a day wakes the radio 2,880 times daily to learn nothing. This is the single most common self-inflicted battery problem and it is usually the easiest to fix.
Push notifications exist precisely for this. FCM and APNs use a single system-level connection shared across every app on the device, so a push costs a small fraction of what your own polling connection costs. Moving from polling to push is frequently the largest single battery win available.
Where you genuinely must poll, poll on the OS’s terms. WorkManager’s minimum periodic interval of fifteen minutes exists for this reason, and the OS batches your job with other apps’ work so the radio wakes once for several apps rather than once each.
Cause 3: Location, and the accuracy you did not need
GPS is among the most expensive things a phone can do. Requesting high accuracy continuously will drain a battery in hours, and a great many apps request far more precision than their feature needs.
// Expensive: continuous GPS at maximum precision
val request = LocationRequest.Builder(PRIORITY_HIGH_ACCURACY, 1_000L).build()
// Usually sufficient: coarse, infrequent, and batched
val request = LocationRequest.Builder(PRIORITY_BALANCED_POWER_ACCURACY, 300_000L)
.setMinUpdateIntervalMillis(120_000L)
.setMaxUpdateDelayMillis(600_000L) // allows the OS to batch deliveries
.build()
setMaxUpdateDelayMillis is the underused one. It permits the system to hold updates and deliver them in a batch, which means the radio and GPS wake far less often. If your feature does not need updates the instant they occur, this alone can cut location cost dramatically.
Ask honestly what precision the feature requires. “Which city is the user in” needs coarse location checked occasionally, not metre-accurate continuous tracking. And geofencing, where the OS notifies you on entering a region, is far cheaper than polling location to determine the same thing yourself.
Cause 4: Chatty, unbatched networking
The cellular radio does not switch off the moment your request completes. It stays in a high-power state for several seconds, then steps down through intermediate states before idling — the tail energy problem. Ten requests spaced twenty seconds apart hold the radio near full power continuously, while ten requests fired together pay the tail cost once.
So batch. Queue non-urgent requests and flush them together, ideally when the radio is already awake for something else. Compress payloads, because transfer time is radio time. And cache aggressively so repeat requests never leave the device.
Analytics libraries are worth auditing specifically here: many default to sending each event immediately, and an app firing an analytics call on every screen view is holding the radio awake continuously during a browsing session. Almost all of them support batching, and almost nobody turns it on.
Cause 5: Work that keeps running when the UI is gone
Animations, timers, and observers that are not cancelled when the screen goes away will keep the CPU busy indefinitely. On Android, a coroutine launched outside a lifecycle-aware scope outlives the screen that started it. On iOS, a repeating Timer on the main run loop keeps firing until invalidated.
The general fix is to tie work to lifecycle rather than to object creation — viewModelScope or repeatOnLifecycle on Android, cancellation in onDisappear or deinit on iOS. Recomposition-driven work in Compose deserves particular care, since a poorly keyed effect can restart on every frame; the patterns in our Compose performance guide apply directly.
Cause 6: Sensors and Bluetooth left running
Sensor subscriptions are easy to start and easy to forget. An accelerometer registered at the fastest delivery rate will wake the CPU continuously, and unlike a network call there is nothing visible happening to remind you it is on.
// Fastest rate: continuous CPU wakeups, rarely necessary
sensorManager.registerListener(this, accelerometer, SENSOR_DELAY_FASTEST)
// Matched to the actual need, and unregistered with the lifecycle
sensorManager.registerListener(this, accelerometer, SENSOR_DELAY_NORMAL)
override fun onPause() {
super.onPause()
sensorManager.unregisterListener(this) // easily forgotten, expensive to forget
}
The rule is to request the slowest delivery rate that satisfies the feature and to unregister in the lifecycle callback that pairs with registration. A step counter does not need SENSOR_DELAY_FASTEST; a game might, and a game is in the foreground where the user can see why their battery is moving.
Bluetooth Low Energy has the same shape with a sharper edge. A BLE scan running continuously is one of the most expensive things an app can do, which is why both platforms restrict background scanning aggressively. Use filtered scans so the system can offload matching to the Bluetooth controller rather than waking your process for every advertisement, and stop scanning the moment you have found what you were looking for.
What good actually looks like
It helps to have a target rather than a vague sense that less is better. For an app that is not actively in use, a reasonable expectation is: zero wakelocks held, no more than a handful of scheduled wakeups per hour, no location updates unless the feature genuinely requires background location, and no network activity outside batched sync windows.
Measured over an hour with the screen off, an app meeting that bar should be a rounding error in the device’s battery breakdown. If yours is showing a visible percentage, something is running that you have not accounted for — and the tools above will name it within one capture.
Be wary of comparing your numbers against other apps in the breakdown, though. The OS attributes some shared work to whichever app triggered it, so an app that triggers a lot of system activity can look worse than its own code suggests, and vice versa. Compare your app against its own previous release rather than against your neighbours; the trend is the reliable signal.
Building a habit rather than a fire drill
The reason battery bugs reach production is that nobody looks until users complain. A modest amount of process prevents most of it: run a battery capture before each release with the device unplugged and the app idle for thirty minutes, and treat any wakelock time or alarm count that has grown since last release as a regression to explain.
Watch the Play Console’s Android vitals and Xcode Organizer’s energy metrics, which give you real-world data from real devices rather than your desk. And when you are already profiling startup cost, battery is worth checking in the same session, since the two frequently share a root cause in work that runs eagerly rather than when needed — our baseline profiles guide covers the startup half of that.
The uncomfortable truth is that an idle app should be almost invisible. If your app appears in the device’s battery breakdown at all after an hour in a pocket, something is waking it that probably should not be.