Battery & Power Explained

PERFORMANCE › Runtime

Every background-execution question in an Android interview traces back to one idea: if nobody is touching the device, your app's work should wait. Doze mode is the mechanism that enforces this. The system enters Doze once the device is unplugged from power, sitting physically still, and has had its screen off for some time. Any one of those flipping, motion, a screen turning on, plugging in, exits Doze immediately.

Once Doze engages, background CPU and network access are restricted, and deferred work only gets to run during short maintenance windows. Wake locks are notably ignored while Doze is active, holding one does not keep the CPU running the way it normally would. That is exactly why a background service that just grabs a wake lock and loops is an interview red flag: it burns battery for as long as it can, then gets cut off anyway once Doze takes hold, and the interviewer wants to hear that you would replace it with a mechanism that cooperates with the OS instead of fighting it.

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

WorkManager is Google's recommended API for deferrable, guaranteed background work precisely because it already understands Doze. Under the hood it schedules through JobScheduler, so a request you enqueue is subject to the same deferral as everything else in the system. If you enqueue a plain OneTimeWorkRequest with no constraints while the device is deep in Doze, the job is not lost and it is not cancelled, it simply waits until the next maintenance window opens, or until Doze ends entirely. WorkManager also persists its queue across reboots and process death, so a deferred job is still sitting there waiting when the device eventually wakes up. The core message to give in an interview is that WorkManager does not fight Doze, it cooperates with it, which is why it is the right default for anything that does not need to happen this second.

val request = OneTimeWorkRequestBuilder<UploadWorker>().build()
WorkManager.getInstance(context).enqueue(request)

Maintenance windows are not evenly spaced. The system schedules them progressively less often the longer a device stays continuously idle, trading punctuality for battery savings the longer nobody has touched the phone. A device that just entered Doze a minute ago gets a maintenance window relatively soon, one that has been idle overnight gets far sparser windows. This matters for how you reason about worst-case latency: a WorkManager job enqueued without constraints does not have a fixed upper bound on when it runs, the bound gets looser the longer the device sits untouched, which is exactly why time-sensitive work should never rely on an unconstrained deferrable job alone.

Separate from Doze, every app on the device is assigned to an App Standby Bucket, and that bucket is decided purely by how recently and how often the user actually opens and interacts with the app, not by anything the app itself declares. From least to most restricted the buckets are Active, meaning in use right now, Working set, used often, Frequent, used regularly but not daily, Rare, rarely used, and Restricted, the harshest tier. There is also a special Never bucket for apps that are installed but have never actually been opened. An app whose own developer considers its background sync critical still lands in Restricted if the user has stopped opening it, the bucket reflects the user's relationship with the app, not the app's opinion of itself.

The Restricted bucket is where App Standby gets teeth. An app parked there gets roughly one batched job session a day, coalesced together with other apps' deferred work rather than run on its own schedule, about one alarm a day, and severely limited network access. These limits apply even while the device is charging, unless it is also sitting idle on an unmetered network. Interviewers ask about Restricted specifically because it is the bucket that breaks naive assumptions, an app cannot simply assume its sync job will fire hourly just because the phone is plugged into power overnight, if the user has stopped engaging with the app, the system will still throttle it hard.

Constraints let you describe the conditions under which deferrable work is actually okay to run, rather than polling or guessing yourself. A large upload is the textbook case, you do not want it burning a metered data plan or draining a battery that is not plugged in. Requiring charging plus an unmetered network means the upload waits until the device is on power and on Wi-Fi, which is the battery- and data-friendly choice. Other constraints follow the same declarative pattern, setRequiresBatteryNotLow and setRequiresDeviceIdle both describe a condition rather than a schedule, and WorkManager decides the exact moment the condition is satisfied.

val constraints = Constraints.Builder()
    .setRequiresCharging(true)
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .build()

Sometimes waiting for the next maintenance window genuinely is not good enough, some alarms need to fire close to on time even inside Doze. AlarmManager.setAndAllowWhileIdle and setExactAndAllowWhileIdle exist for exactly that, they are allowed to fire during Doze, but the system rate-limits each app to roughly once every nine minutes, so chaining them cannot be used to sidestep Doze's battery savings altogether. setAlarmClock also wakes the device, and is meant for genuine user-facing alarms rather than background bookkeeping. The nine-minute figure is one of those specific numbers interviewers like to hear you produce unprompted, it signals you have actually read the platform docs rather than just knowing the API names.

val alarmManager = getSystemService(AlarmManager::class.java)
alarmManager.setExactAndAllowWhileIdle(
    AlarmManager.ELAPSED_REALTIME_WAKEUP,
    SystemClock.elapsedRealtime() + delayMs,
    pendingIntent
)

Any exact alarm also runs into a permission gate on modern Android. Since Android 13, SCHEDULE_EXACT_ALARM is no longer granted by default to newly installed apps, it is a permission the user can deny or revoke later in Settings, so an app must always check canScheduleExactAlarms before relying on one and handle the case where it is missing. USE_EXACT_ALARM is a separate, more tightly gated permission reserved for apps whose core purpose is genuine alarm clocks or calendar reminders, using it for a general reminders feature or a routine timer is a policy violation, not a shortcut.

For content that is genuinely time-critical and user-visible, an incoming call, a chat message somebody is waiting on, the right tool is not an alarm or a deferrable job at all, it is an FCM high-priority message. Sent while the device is idle, a high-priority message briefly wakes the app out of Doze or standby, granting it temporary network access and a wake lock so it can actually process the payload and post a notification. This is the one path in the whole topic that is allowed to interrupt Doze on demand rather than waiting for a maintenance window, and it exists precisely so messaging apps do not need to hold a permanent wake lock or poll a server to stay responsive.

High priority is not the default, and it should not be. A normal-priority FCM message gets no special treatment during Doze, it is simply held by the system and delivered at the next maintenance window or once Doze ends, the same as any other deferred work. This is a deliberate design choice, if every message could interrupt Doze on arrival there would be no battery savings left to speak of. It also means high priority is meant to be reserved for genuinely urgent, user-visible content, using it for routine background sync is both against Google's guidance and, over time, gets those messages quietly deprioritized by the delivery system anyway.

WorkManager's setExpedited gives a job elevated, near-foreground importance so it runs as soon as possible rather than waiting for a maintenance window, which sounds like a way around everything covered so far. It is not unlimited, though, each app has an expedited-work quota, and that quota is itself tied to the app's App Standby Bucket, an app in Restricted gets far less expedited headroom than one in Active. An OutOfQuotaPolicy decides what happens once the quota runs out, typically falling back to running the work as ordinary, non-expedited WorkManager instead of failing outright. The pattern to recognize is that every escape hatch in this topic, expedited work included, is metered by the same standby-bucket accounting rather than being a true bypass.

val request = OneTimeWorkRequestBuilder<SyncWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

None of the deferrable or rate-limited mechanisms covered so far fit continuous, user-aware work, live GPS tracking during a workout, an active call, ongoing media playback. That is what a foreground service is for. It runs with elevated priority and is not subject to Doze deferral the way WorkManager or alarms are, precisely because it must show a persistent notification, the user can see it running the entire time, which is the tradeoff that earns it the exemption. Since Android 10 a foreground service also has to declare a foregroundServiceType, such as location, so the system and the user both know what kind of ongoing work is being granted this privilege.

startForeground(
    NOTIFICATION_ID,
    notification,
    ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
)

REQUEST_IGNORE_BATTERY_OPTIMIZATIONS lets a user manually exempt an app from Doze and App Standby entirely, but it is not a general-purpose fix for an app that has not designed its background work correctly. Google Play policy reserves it for a narrow set of justified categories, things like certain messaging apps or device-management tools, and apps outside those categories can be rejected during review for requesting it. The recommended path for the overwhelming majority of apps is the opposite of an exemption, schedule work with WorkManager and Constraints, use FCM high-priority for the genuinely urgent case, and let Doze do its job.

Diagnosing real battery drain, rather than reasoning about it from documentation, means capturing a bug report and loading it into Battery Historian, which visualizes exactly what kept the device awake, what held wake locks, and when jobs and alarms actually fired. The usual sequence is to reset stats, reproduce the drain scenario, then pull a full bug report and load that file into the tool, a live USB connection or a raw dumpsys command pasted into a text box will not do. For faster iteration while developing, adb shell dumpsys deviceidle force-idle forces the device into Doze on demand, adb shell am set-standby-bucket lets you move an app into a specific bucket like Restricted to test its behavior directly, and adb shell dumpsys jobscheduler shows what WorkManager and JobScheduler currently have queued and why.

adb shell dumpsys batterystats --reset
adb bugreport bugreport.zip
# load bugreport.zip into Battery Historian

Back to Battery & Power