Diagnosing Battery Drain in Android Apps

Dhruval Dhameliya·September 30, 2025·8 min read

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.

PriorityAccuracyBattery ImpactUse Case
HIGH_ACCURACY~3mVery HighNavigation
BALANCED_POWER_ACCURACY~100mModerateCity-level features
LOW_POWER~1kmLowRegional content
PASSIVEVariesNegligibleOpportunistic 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

ApproachBattery SavingsFeature Impact
Push over pollHighRequires FCM infrastructure
Consolidated sync workerMediumFeatures lose independent scheduling
Low-power locationMediumReduced accuracy
Battery-aware degradationVariableDegraded experience on low battery
Passive location onlyHighInfrequent, 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: PeriodicWorkRequest with 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 LocationCallback in onStop means 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 finally blocks.
  • 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

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