Android Power Management: From Wakelock Abuse to Doze-Aware Engineering

While investigating user feedback once, I found that battery drain had jumped by 30% after a release. A Battery Historian report made the cause obvious: one Wakelock stayed held for 40 minutes. A network timeout had not been caught correctly, so WakeLock.release() never ran.

That is the classic entry point for Android battery problems: Wakelock misuse.

What a Wakelock really does, and how to use it safely

A Wakelock prevents the CPU from going to sleep. After the screen turns off, the system gradually lowers CPU frequency and eventually suspends execution. A Wakelock interrupts that process.

PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
PowerManager.WakeLock wl = pm.newWakeLock(
    PowerManager.PARTIAL_WAKE_LOCK, 
    "MyApp:NetworkRequest"  // Use a meaningful tag so Battery Historian can identify it
);
wl.acquire(10 * 60 * 1000L); // 10-minute timeout guard; strongly recommended
try {
    doNetworkWork();
} finally {
    if (wl.isHeld()) wl.release(); // Release in finally; do not skip this
}

PARTIAL_WAKE_LOCK is the most common type. It keeps the CPU running without affecting the screen. Calling acquire() with a timeout is the minimum safety bar. A Wakelock without a timeout becomes a time bomb if an exception skips release().

There is another trap in multithreaded code: a race exists between isHeld() and release(). If multiple threads may call release, use reference-counted acquisition. acquire() can be called multiple times, and each release() decrements the count. The lock is actually released only when the count reaches zero.

The two levels of Doze

Android 6.0 introduced Doze, and Android 7.0 extended it into two levels. The trigger conditions differ, and background task behavior changes with them.

Doze Level 1, or Deep Doze: triggered after the device has been stationary, unplugged, and screen-off for roughly 30 minutes. Once the device enters this state, the system periodically opens Maintenance Windows that allow network access and task execution. The interval between windows grows over time and can eventually reach several hours.

Doze Level 2, or Light Doze: triggered when the screen is off but the device is moving, or shortly after the screen turns off. Restrictions are lighter. Network access is limited, but JobScheduler can still run.

// Check whether the device is currently in Doze
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
if (pm.isDeviceIdleMode) {
    // In deep Doze: network is unavailable and AlarmManager does not fire normally
    // Only alarms with FLAG_ALLOW_WHILE_IDLE can run during an allowed window
}

The core Doze restrictions for background work are straightforward: exact AlarmManager timing becomes unreliable, network access pauses, and JobScheduler work is deferred until a Maintenance Window. Any background flow that depends on AlarmManager.setExact() is not reliable under Doze.

The alternative is setExactAndAllowWhileIdle(), but that API is rate-limited. Each app can trigger it at most once every 9 minutes. If a feature truly needs precise timing, such as music playback or a timer, use a foreground service instead of betting on alarms.

App Standby and bucket-based limits

App Standby applies limits from another direction. It places apps into buckets based on usage frequency:

  • Active: the user is currently using the app; no restrictions
  • Working Set: used often; light restrictions
  • Frequent: used occasionally; JobScheduler frequency is limited
  • Rare: rarely used; network quota is reduced and task frequency drops sharply
  • Restricted (Android 12+): almost never used; roughly one Job execution window per day
// Query the current App Standby bucket
// Requires QUERY_ALL_PACKAGES or system-level permission in many cases
val usm = getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
val bucket = usm.appStandbyBucket
// Return value maps to UsageStatsManager.STANDBY_BUCKET_* constants

One detail is easy to miss in real projects: FCM’s push notification connection is managed by the system, so buckets do not affect it in the same way. But if an app maintains its own long-lived connection for push delivery, the Rare bucket can delay messages because network access is restricted. Many unstable background-push implementations are caused by this. The problem is not FCM. It is the app’s own connection being limited by the system.

Diagnosing battery issues with Battery Historian

Battery Historian is Google’s battery analysis tool. It turns a bugreport file into a visual report, and you can run it locally with Docker:

# Capture a bugreport; requires an ADB-connected device
adb bugreport bugreport.zip

# Run Battery Historian with Docker
docker run -d -p 9999:9999 gcr.io/android-battery-historian/stable:3.0 --port 9999

# Open http://localhost:9999 in the browser and upload bugreport.zip

Focus on these lanes in the report:

Wakelock lane: each colored block represents one Wakelock hold. Longer blocks are more suspicious, and the tag name often points directly to the code path.

JobScheduler lane: execution time and frequency for Jobs. Many short Jobs usually mean the work is split too aggressively and should be batched.

Sync lane: account sync operations. Dense sync requests usually mean you should inspect ContentResolver.setSyncAutomatically usage.

Mobile Radio lane: mobile radio active time. Radio wakeups are expensive. Ideally, requests are batched and the radio shuts down quickly. Repeated small wakeups are a problem worth fixing.

In real projects, the most valuable use of Battery Historian is not discovering the first bug. It is validating the optimization result. After a change, capture two bugreports and compare them. How much did Wakelock hold time drop? Did Job execution frequency decrease? Data is more trustworthy than subjective impressions.

The right way to use JobScheduler

Most background work should use JobScheduler, or more often its higher-level wrapper, WorkManager. It is usually a mistake to build this with Service plus Wakelock. Centralized system scheduling and batched execution are already power-friendly by design.

// WorkManager is the recommended higher-level API over JobScheduler
val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)  // Do not run when battery is low
    .build()

val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
    repeatInterval = 1,
    repeatIntervalTimeUnit = TimeUnit.HOURS
)
    .setConstraints(constraints)
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.MINUTES)
    .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "periodic_sync",
    ExistingPeriodicWorkPolicy.KEEP,
    syncRequest
)

setRequiresBatteryNotLow() is often forgotten. Adding it avoids making a bad battery situation worse. BackoffPolicy.EXPONENTIAL ensures retry intervals grow after failures instead of retrying blindly.

On API 23 and above, WorkManager uses JobScheduler internally. On older versions, it falls back to AlarmManager. The framework handles compatibility. My recommendation is to use WorkManager directly unless you need unusually precise control over scheduling behavior. In most cases, raw JobScheduler does not buy you much.

Practical rules you can apply

Always put a timeout on Wakelocks. acquire(timeoutMillis) is a fuse. Without it, one uncaught exception can leak a Wakelock until the device reboots. Set the timeout to about 1.5x the expected maximum execution time.

Use Battery Historian for pre-release regression checks. Put bugreport collection into the QA flow. Any change that touches background behavior should get a before-and-after comparison. Waiting for user feedback makes the investigation far more expensive.

Separate “needs exact timing” from “needs reliable execution.” Use a foreground service for the former and WorkManager for the latter. Mixing them into a Service plus Wakelock implementation is the root cause of many battery problems.