Diagnosing Battery Drain in Android Apps
A structured methodology for identifying and fixing battery drain in Android apps, covering wake locks, location updates, background work, and network polling patterns.
Context
Battery drain complaints are uniquely difficult to debug. Users report "your app drains my battery" without any actionable data. The Play Console vitals dashboard shows aggregate stuck wake lock rates and excessive background Wi-Fi scans, but rarely points to specific code. Unlike a crash, which has a stack trace, battery drain is a slow, cumulative effect of many small decisions.
Problem
Battery drain in Android apps comes from five primary sources: CPU wake locks, network activity, location updates, sensor usage, and excessive AlarmManager or JobScheduler executions. In large apps, these sources are scattered across features built by different teams, each making locally reasonable decisions that combine into a globally unreasonable battery impact.
A single team adding a 15-minute periodic sync is fine. Ten teams doing the same thing means the device wakes up every 90 seconds.
Constraints
- Must identify battery impact per feature, not just per app
- Must work within Android's background execution limits (Doze, App Standby, background location throttling)
- Must not degrade user-facing functionality
- Fixes must be validated on real hardware (emulators do not model power states accurately)
- Must handle OEM-specific battery optimizations (Samsung, Xiaomi, Huawei each add restrictions)
Design
Source 1: Wake Locks
Wake locks prevent the device from sleeping. A forgotten PARTIAL_WAKE_LOCK is the most common cause of battery drain.
See also: Event Tracking System Design for Android Applications.
// Dangerous: acquiring a wake lock without timeout
class SyncService : Service() {
private lateinit var wakeLock: PowerManager.WakeLock
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "app:sync")
wakeLock.acquire() // no timeout, will drain battery if not released
doSync()
return START_NOT_STICKY
}
// If doSync() throws, the wake lock is never released
}
// Safe: timeout and try/finally
class SafeSyncWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val pm = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
val wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "app:sync_worker"
)
wakeLock.acquire(10 * 60 * 1000L) // 10-minute timeout
return try {
performSync()
Result.success()
} catch (e: Exception) {
Result.retry()
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
}
}Detection strategy:
// Debug build: detect unreleased wake locks
class WakeLockTracker {
private val activeLocks = ConcurrentHashMap<String, Long>()
fun onAcquire(tag: String) {
activeLocks[tag] = SystemClock.elapsedRealtime()
}
fun onRelease(tag: String) {
activeLocks.remove(tag)
}
fun reportLeaks() {
val now = SystemClock.elapsedRealtime()
activeLocks.forEach { (tag, acquireTime) ->
val heldMs = now - acquireTime
if (heldMs > 5 * 60 * 1000) { // held > 5 minutes
Log.e("WakeLockLeak", "$tag held for ${heldMs / 1000}s")
}
}
}
}Source 2: Location Updates
Location is the second largest battery drain source. GPS hardware consumes 25-50mA of current.
| Priority | Accuracy | Battery Impact | Use Case |
|---|---|---|---|
| HIGH_ACCURACY | ~3m | Very High | Navigation |
| BALANCED_POWER_ACCURACY | ~100m | Moderate | City-level features |
| LOW_POWER | ~1km | Low | Regional content |
| PASSIVE | Varies | Negligible | Opportunistic updates |
class LocationManager @Inject constructor(
private val fusedClient: FusedLocationProviderClient,
private val batteryMonitor: BatteryMonitor
) {
fun requestUpdates(priority: Int = Priority.PRIORITY_BALANCED_POWER_ACCURACY): Flow<Location> =
callbackFlow {
val request = LocationRequest.Builder(priority, 30_000L) // 30s interval
.setMinUpdateIntervalMillis(10_000L)
.setMaxUpdateDelayMillis(60_000L) // batch for efficiency
.setWaitForAccurateLocation(false)
.build()
// Downgrade priority on low battery
val effectiveRequest = if (batteryMonitor.isLow()) {
request.toBuilder()
.setPriority(Priority.PRIORITY_LOW_POWER)
.setIntervalMillis(120_000L)
.build()
} else {
request
}
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { trySend(it) }
}
}
fusedClient.requestLocationUpdates(effectiveRequest, callback, Looper.getMainLooper())
awaitClose { fusedClient.removeLocationUpdates(callback) }
}
}Source 3: Network Polling
Periodic network requests wake the radio, which takes 10-20 seconds to return to idle, consuming power even after the request completes.
// Bad: polling every 30 seconds
class NaivePoller(private val api: Api) {
fun startPolling() = flow {
while (true) {
emit(api.fetchUpdates())
delay(30_000) // wakes radio every 30s
}
}
}
// Better: use FCM for push, poll only as fallback
class SmartUpdater @Inject constructor(
private val api: Api,
private val connectivityMonitor: ConnectivityMonitor
) {
// Primary: push via FCM (no polling needed)
// Fallback: exponential backoff polling only when push is unavailable
fun updates(): Flow<Update> = channelFlow {
val pushFlow = fcmUpdates() // zero battery cost
val pollFlow = fallbackPoll()
merge(pushFlow, pollFlow).collect { send(it) }
}
private fun fallbackPoll(): Flow<Update> = flow {
var interval = 60_000L // start at 1 minute
val maxInterval = 15 * 60_000L // cap at 15 minutes
while (true) {
if (!connectivityMonitor.hasPush()) {
try {
emit(api.fetchUpdates())
interval = 60_000L // reset on success
} catch (e: Exception) {
interval = (interval * 2).coerceAtMost(maxInterval)
}
}
delay(interval)
}
}
}Source 4: WorkManager Scheduling
// Bad: every feature schedules its own periodic work independently
// Feature A: every 15 minutes
// Feature B: every 20 minutes
// Feature C: every 30 minutes
// Result: device wakes up constantly
// Better: consolidate into a single sync worker
class ConsolidatedSyncWorker(
context: Context, params: WorkerParameters
) : CoroutineWorker(context, params) {
@Inject lateinit var syncTasks: Set<@JvmSuppressWildcards SyncTask>
override suspend fun doWork(): Result {
val results = syncTasks.map { task ->
try {
task.execute()
true
} catch (e: Exception) {
Log.e("Sync", "Task ${task.name} failed", e)
false
}
}
return if (results.all { it }) Result.success() else Result.retry()
}
companion object {
fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val request = PeriodicWorkRequestBuilder<ConsolidatedSyncWorker>(
30, TimeUnit.MINUTES,
5, TimeUnit.MINUTES // flex window
)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"consolidated_sync",
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
}
}
interface SyncTask {
val name: String
suspend fun execute()
}Source 5: Battery-Aware Behavior
class BatteryMonitor @Inject constructor(
private val context: Context
) {
fun isLow(): Boolean {
val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) < 20
}
fun isCharging(): Boolean {
val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return bm.isCharging
}
fun batteryState(): Flow<BatteryState> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
trySend(BatteryState(level, charging))
}
}
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
awaitClose { context.unregisterReceiver(receiver) }
}
}
data class BatteryState(val level: Int, val isCharging: Boolean)Trade-offs
| Approach | Battery Savings | Feature Impact |
|---|---|---|
| Push over poll | High | Requires FCM infrastructure |
| Consolidated sync worker | Medium | Features lose independent scheduling |
| Low-power location | Medium | Reduced accuracy |
| Battery-aware degradation | Variable | Degraded experience on low battery |
| Passive location only | High | Infrequent, less reliable updates |
Failure Modes
- OEM battery killers: Samsung, Xiaomi, and Huawei add aggressive app-killing behavior beyond stock Android. Users must manually whitelist apps. Detect and guide users through settings.
- WorkManager inexact timing:
PeriodicWorkRequestwith a 30-minute interval may execute hours later under Doze. Do not rely on exact timing for background work. - Wake lock timeout too short: a 30-second wake lock may not be enough for a large sync. The sync gets killed mid-way, leading to data corruption. Size timeouts based on measured sync duration.
- Location updates after unsubscribe: failing to remove the
LocationCallbackinonStopmeans GPS stays active in the background.
Scaling Considerations
- Create a centralized "background work registry" where all teams register their periodic tasks. This prevents schedule collision.
- Implement a battery impact dashboard that attributes drain to specific features using custom traces.
- Enforce WorkManager usage over raw AlarmManager or Handler.postDelayed in code review and lint rules.
Observability
- Track per-feature background execution time and frequency
- Monitor Play Console battery vitals (stuck wake locks, excessive background Wi-Fi scans)
- Log wake lock acquire/release with durations in debug builds
- Alert on stuck wake lock rate exceeding 0.1% per release
Key Takeaways
- Wake locks must always have timeouts and be released in
finallyblocks. - Replace polling with push notifications wherever possible. The radio power cost of polling is disproportionate to the data transferred.
- Consolidate background work into a single periodic worker to minimize device wake-ups.
- Adjust behavior based on battery state. High-accuracy location and frequent syncs should degrade gracefully.
- Test battery impact on real devices. Emulators do not model power states.
- OEM-specific battery optimizations break standard Android background work. Account for them in your design.
Further Reading
- Debugging Performance Issues in Large Android Apps: A systematic approach to identifying, isolating, and fixing performance bottlenecks in large Android codebases, covering profiling strate...
- Debugging Heisenbugs in Android Apps: Strategies for diagnosing and fixing bugs that disappear when observed, covering race conditions, timing-dependent failures, and non-dete...
- How I Profile Android Apps in Production: Techniques for collecting meaningful performance data from production Android apps without degrading user experience, covering sampling s...
Final Thoughts
Battery drain is a tragedy of the commons in large apps. Each feature's background work is individually modest, but the sum is devastating. The solution is centralized coordination: a single sync worker, a single location strategy, a single push infrastructure. Treat battery as a shared budget, not an unlimited resource that each team spends independently.
Recommended
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.
Understanding ANRs: Detection, Root Causes, and Fixes
A systematic look at Application Not Responding errors on Android, covering the detection mechanism, common root causes in production, and concrete strategies to fix and prevent them.