Battery Optimization on Android: How the System Actually Manages Power
1. The power management stack
Android's battery behavior is the product of four layers acting together, and most user-facing "optimization" advice only touches the top one:
- Kernel — cpufreq/cpuidle governors, wakelock accounting (
/sys/power/wake_lock,wake_unlock), and suspend state (echo mem > /sys/power/stateequivalents triggered by the framework). - HAL / SystemServer —
PowerManagerService,DeviceIdleController(Doze),AppStandbyController,JobSchedulerService, andAlarmManagerService. - Framework APIs —
WorkManager,JobScheduler,AlarmManager,FusedLocationProviderClient, exposed to apps and constrained by the layer above. - OEM overlays — vendor-specific background killers (MIUI, EMUI/Magic UI, ColorOS, One UI) that sit outside AOSP's Doze/Standby contract and are the most common source of unpredictable behavior.
Everything below is AOSP behavior unless the OEM layer is called out explicitly.
2. Doze mode
Doze is triggered by DeviceIdleController when the device is unplugged, screen off, and stationary (confirmed via the significant motion sensor) for a period of time. It runs in two nested tiers.
Light Doze
Triggers faster — screen off and unplugged is close to sufficient, without requiring stationarity. Network access is throttled but not fully suspended, and maintenance windows recur on a short cycle. This is the tier most phones spend the majority of idle screen-off time in.
Deep Doze
Requires the stationary condition. The device cycles between an idle state and short maintenance windows on an exponential backoff (roughly starting at minutes, extending toward the ~1 hour range on later cycles, capped and reset by the device on a full wake or motion event). During idle:
- Network access is suspended for apps except those holding a high-priority FCM message or a Doze allowlist exemption.
- The system ignores wakelocks and defers
WakeLock-triggered work. - Wi-Fi scans, GPS, and sensor batching are deferred.
- Standard alarms (
AlarmManager.set(),setExact()) are deferred to the next maintenance window. - Only
setAndAllowWhileIdle()andsetExactAndAllowWhileIdle()alarms fire during idle, and the system rate-limits how often a single app can use them.
Doze allowlisting ("Unrestricted" battery setting, or dumpsys deviceidle whitelist) exempts an app from network suspension and job deferral during Doze, but does not exempt it from App Standby Bucket throttling — those are separate mechanisms with separate exemption paths.
3. App Standby Buckets
Introduced in Android 9, buckets throttle background activity per-app based on predicted usage, independent of Doze's device-wide state. AppStandbyController assigns a bucket using a combination of usage recency, explicit user interaction, and — on devices with Adaptive Battery enabled — an on-device ML model trained on usage patterns.
| Bucket | Typical trigger | Job/alarm throttling | FCM impact |
|---|---|---|---|
active | App in foreground or recently interacted with | None | Immediate delivery |
working_set | Used regularly, not currently active | Jobs deferred; ~2 alarm windows/hour | Near-immediate |
frequent | Used often but not daily-driver | Further job batching; ~1 window/hour | Slight delay possible |
rare | Infrequently used | Jobs batched to ~1 window per several hours; network access limited to daily windows | Delayed, batched with other traffic |
restricted | Unused for an extended period, or user force-stopped repeatedly | Job execution effectively disabled outside rare batched windows; no background network by default | High-priority messages only, heavily delayed |
Buckets are re-evaluated continuously; an app can move from rare back to active the moment the user opens it. The restricted bucket is the one most responsible for "notifications arrive late" complaints, and it's usually a self-inflicted state from the user force-stopping the app repeatedly rather than a system default.
Checking an app's current bucket
# requires a debug build or root for full output on some OEM skins
adb shell am get-standby-bucket com.example.app
# full standby state dump
adb shell dumpsys usagestats | grep -A 3 "package: com.example.app"
4. Background execution limits (Android 8+)
Independent of Doze and Standby, Oreo introduced hard limits that apply whenever the app is not in the foreground:
- Implicit broadcasts — most implicit broadcasts (e.g.
CONNECTIVITY_ACTION) can no longer be received by manifest-declared receivers; apps must register them at runtime while active. - Background service starts —
Context.startService()from the background throwsIllegalStateExceptionon targetSdk 26+. Apps must usestartForegroundService()and post a notification within 5 seconds, or useWorkManager/JobSchedulerinstead. - Location updates — background location delivery is throttled to a few updates per hour regardless of requested interval, unless the app holds the
ACCESS_BACKGROUND_LOCATIONpermission (Android 10+) and the update is justified in a foreground service or a WorkManager job.
JobScheduler and its WorkManager wrapper are the sanctioned replacement for background Service polling: the system batches jobs from multiple apps into shared wake windows instead of letting each app wake the radio and CPU independently.
5. Wakelocks
A wakelock is a kernel-level hold that prevents the SoC from entering a deep idle state. The framework exposes them via PowerManager.WakeLock; the kernel tracks them by name in /sys/power/wake_lock (held) and /sys/power/wake_unlock (released).
| Type | Effect | Status |
|---|---|---|
PARTIAL_WAKE_LOCK | Keeps CPU running, screen and radio can still sleep independently | Standard for background work |
SCREEN_DIM_WAKE_LOCK | Keeps screen on dim + CPU | Deprecated API 17 |
SCREEN_BRIGHT_WAKE_LOCK | Keeps screen at full brightness + CPU | Deprecated API 17 |
FULL_WAKE_LOCK | Screen on, keyboard bright, CPU running | Deprecated API 17, avoid |
A partial wakelock acquired without a timeout and never released is the single most common cause of anomalous overnight drain. It survives Doze's normal wakelock-ignoring behavior if the app was allowlisted, and it prevents deep idle even outside Doze. Always check acquire(timeout) usage when profiling a specific app.
# list currently held wakelocks and their duration
adb shell dumpsys power | grep -A2 "Wake Locks"
# kernel-level wakelock accounting (aggregate time held per source)
adb shell cat /sys/kernel/debug/wakeup_sources
6. AlarmManager
Exact alarms are expensive because they force a wake regardless of batching opportunity. The API surface pushes toward inexactness by default:
| Method | Doze behavior | Batching |
|---|---|---|
set() | Deferred to next maintenance window | Batched with other inexact alarms |
setExact() | Deferred to next maintenance window | Not batched, but still deferred |
setAndAllowWhileIdle() | Fires during idle, rate-limited | No |
setExactAndAllowWhileIdle() | Fires during idle at exact time, rate-limited | No |
setAlarmClock() | Always fires exactly, exempts app from Doze briefly | No — reserved for user-facing alarms |
7. Radio power states
Cellular and Wi-Fi radios don't transition instantly between off and active. LTE/5G modems follow an RRC (Radio Resource Control) state machine: idle → a high-power connected state on data activity → a lingering "tail" at high power for several seconds after the last packet, before stepping back down. That tail is why five small requests spaced a minute apart cost meaningfully more energy than one batched request carrying the same payload — each one re-triggers the ramp-up and tail.
This is the underlying reason push (FCM) beats polling: a single persistent connection amortizes the ramp cost across all apps using it, instead of each app independently waking the radio on its own schedule.
8. Location power cost
| Priority (FusedLocationProviderClient) | Source | Relative power cost |
|---|---|---|
PRIORITY_HIGH_ACCURACY | GPS + Wi-Fi + cell | Highest |
PRIORITY_BALANCED_POWER_ACCURACY | Wi-Fi + cell (~block-level) | Moderate |
PRIORITY_LOW_POWER | Cell only (~city-level) | Low |
PRIORITY_PASSIVE | Piggybacks on other apps' requests only | Negligible |
Geofencing offloaded to the hardware geofence chip (where present) costs far less than an app polling getCurrentLocation() on a timer, since the comparison runs on a low-power coprocessor instead of waking the applications processor.
9. OEM background management
Several Android skins — most notably MIUI, EMUI/Magic UI, ColorOS, and older One UI versions — layer an additional, non-AOSP background killer on top of Doze/Standby. These use heuristics outside the documented framework contract and are the leading cause of missed notifications on non-Pixel/AOSP-adjacent devices, because they can kill a process even while it holds a valid Doze allowlist exemption.
Granting an app "Unrestricted" battery status in Android settings only affects the AOSP-layer mechanisms (Doze, Standby, background execution limits). It does not disable a manufacturer's separate background app manager — that typically requires a second, OEM-specific toggle (e.g. autostart permission, or an explicit exclusion list in the phone manager app).
10. Diagnostics
# snapshot of current battery state, level, temperature, plug status
adb shell dumpsys battery
# reset accumulated battery stats to start a clean measurement window
adb shell dumpsys batterystats --reset
# force the system to treat the device as unplugged for testing Doze
adb shell dumpsys battery unplug
# step the device through Doze states manually
adb shell dumpsys deviceidle step
# check current Doze state
adb shell dumpsys deviceidle get deep
# pull a full bugreport for Battery Historian ingestion
adb bugreport report.zip
Battery Historian renders the bugreport's batterystats section as a timeline — wakelock hold periods, radio state transitions, and job execution windows plotted per app — which is the fastest way to confirm a suspected culprit rather than guessing from the Settings battery usage percentage breakdown, which only shows foreground/background split, not the mechanism responsible.
11. User-facing settings and what they actually do
| Setting | Underlying mechanism |
|---|---|
| Battery Saver | Reduces background CPU/network priority, disables/limits location, caps refresh rate and animations, restricts vibration and always-on display |
| Adaptive Battery | Enables the on-device ML model driving App Standby Bucket assignment; without it, buckets fall back to simpler recency heuristics |
| App battery usage: Unrestricted | Doze allowlist exemption + exempt from background execution limits |
| App battery usage: Optimized (default) | Full Doze + Standby Bucket enforcement |
| App battery usage: Restricted | Forces the app toward the restricted standby bucket regardless of usage pattern |
| Adaptive brightness | Display power draw scaling — largest single component of screen-on power on OLED panels at high brightness |
| Always-on display | Periodic partial-panel refresh; on OLED, cost scales with lit pixel area, not flat |
Force-stopping an app from Settings or a task killer does not reduce battery use over time the way people expect — it clears the app's process and any in-memory state, but doesn't change its standby bucket favorably, and repeated force-stops are one of the triggers that push an app into the throttled restricted bucket, making its background behavior worse, not better.