Battery & Power Explained

PERFORMANCE › Runtime

Modern Android is aggressive about battery, and the whole system is built around one idea: if the user isn't actively using the device, your app's background work should wait. **Doze mode** is the mechanism. The system enters Doze when the device is **unplugged**, **stationary** (not moving), and has had its **screen off** for a while, any one of those changing, motion, a plug-in, the screen turning on, exits Doze immediately.

Once in Doze, background CPU and network access are restricted, and deferred work only gets to run during periodic **maintenance windows**. This is the frame for the entire topic: the OS decides when your background work runs, your job is to describe that work correctly (deferrable? time-critical? user-visible?) so the right mechanism handles it.

Inside Doze, deferred work includes JobScheduler or WorkManager jobs, sync adapters, standard AlarmManager alarms, and Wi-Fi scans, none of it runs until a maintenance window opens (or Doze ends). Wake locks are notably **ignored**, holding one doesn't keep the CPU running through Doze the way it normally would.

val pm = context.getSystemService(PowerManager::class.java)
if (pm.isDeviceIdleMode) {
    // true only when unplugged + stationary + screen off long enough
    // network/jobs/alarms are currently deferred
}

Maintenance windows themselves shrink over time: the longer the device stays idle, the less frequently they occur, trading punctuality for battery savings the longer you've been away from the device.

Separate from Doze, every app is assigned an **App Standby Bucket** based on how recently and how often the user actually interacts with it, from least to most restricted: **Active** (in use right now), **Working set** (used often), **Frequent** (used regularly but not daily), **Rare** (rarely used), and **Restricted** (barely used, or resource-heavy, roughly 8+ days idle on API 33+). There's also a special **Never** bucket for apps installed but never opened.

The **Restricted** bucket is the harshest: roughly one batched job session per day, shared with other apps' work, about one alarm per day, and severely limited network access, and these limits apply even while charging, unless the device is also idle on an unmetered network. An app that's mostly ignored by its user will get bucketed there regardless of how important its developer thinks its background sync is.

So what actually happens if you enqueue a WorkManager request with no constraints while the device is deep in Doze? WorkManager runs on JobScheduler under the hood, and its jobs are subject to the same deferral as everything else, the work isn't lost, it's just **held** until the next maintenance window or until Doze ends:

val request = OneTimeWorkRequestBuilder<UploadWorker>().build()
WorkManager.getInstance(context).enqueue(request)
// No constraints -> still deferred during Doze, runs at the next maintenance window

This is the core reassurance to give in an interview: WorkManager is the *recommended* API precisely because it already understands Doze and standby, you don't fight the system by using it, you cooperate with it, and your work is guaranteed to eventually run.

Constraints let you describe *when it's actually okay* for deferrable work to run, which is how you make WorkManager behave well without any manual Doze-awareness. A large upload is the textbook case: you don't want it burning cellular data or draining an unplugged battery.

val constraints = Constraints.Builder()
    .setRequiresCharging(true)
    .setRequiredNetworkType(NetworkType.UNMETERED) // Wi-Fi only
    .build()

val uploadRequest = OneTimeWorkRequestBuilder<UploadWorker>()
    .setConstraints(constraints)
    .build()

WorkManager.getInstance(context).enqueue(uploadRequest)

Other useful constraints include setRequiresBatteryNotLow(true) and setRequiresDeviceIdle(true). The pattern to internalize: describe the ideal conditions declaratively, and let WorkManager decide the exact moment, rather than polling or guessing yourself.

Sometimes deferring to the next maintenance window isn't good enough, some alarms genuinely need to fire close to on-time even inside Doze. AlarmManager.setAndAllowWhileIdle() and setExactAndAllowWhileIdle() are built for exactly that, they're allowed to fire during Doze, but the system rate-limits each app to roughly once every **9 minutes**, so you can't use them to sidestep Doze's battery savings by chaining them together.

val alarmManager = getSystemService(AlarmManager::class.java)
alarmManager.setExactAndAllowWhileIdle(
    AlarmManager.ELAPSED_REALTIME_WAKEUP,
    SystemClock.elapsedRealtime() + delayMs,
    pendingIntent
)
// Fires during Doze, but rate-limited to roughly once every ___ minutes per app

Any exact alarm on Android 13+ also needs the user-revocable SCHEDULE_EXACT_ALARM permission (or USE_EXACT_ALARM, reserved for genuine alarm-clock or calendar apps), so always check canScheduleExactAlarms() before relying on one.

For genuinely time-critical, user-visible content, like an incoming call or chat message, the right tool isn't an alarm at all, it's an **FCM high-priority message**. Sent while the device is idle, a high-priority message briefly wakes the app from Doze or standby, granting temporary network access and a wake lock so it can actually handle the payload.

{ "message": { "token": "...", "android": { "priority": "HIGH" } } }

A **normal-priority** message gets no such treatment, during Doze it's simply held and delivered at the next maintenance window, same as everything else. High priority is meant to be reserved for genuinely urgent, user-visible content; overusing it for routine sync gets it deprioritized by the delivery system over time.

When you actually need continuous, user-aware work that the OS should never defer, live GPS tracking during a workout, an ongoing call, active media playback, none of the deferrable APIs fit. That's what a **foreground service** with a persistent notification is for: it runs with elevated priority and isn't subject to Doze deferral the way WorkManager or alarms are, precisely because the user can see it's running.

// AndroidManifest.xml: <service android:foregroundServiceType="location" .../>
startForeground(
    NOTIFICATION_ID,
    notification,
    ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
)

When you do need to diagnose real-world drain rather than reason about it in theory, adb shell dumpsys batterystats --reset, reproduce the scenario, then adb bugreport, and load the resulting file into **Battery Historian** to visualize exactly what kept the device awake.

Back to Battery & Power