DEV Community

David Njoroge
David Njoroge

Posted on

The Power Saving Paradox (Part 2): WorkManager and CoroutineWorker

Mastering the WorkManager and CoroutineWorker: Surviving the Android Power State Machine

In the modern Android ecosystem, the days of spawning a long-running Thread or keeping a Service alive indefinitely are over. If you attempt to do so, the system’s Low Memory Killer (LMK) or Power Management Service will terminate your process without warning.

For Kotlin developers, the primary tool for survival is WorkManager. It is not just a library; it is a high-level abstraction layer that sits on top of the OS’s power-saving features, ensuring your code runs reliably while respecting the user's battery.

In my previous post, we dived deeper to the engineering of power saving and the architectural constraints, visit The Power Saving Paradox (Part 1) to learn more.


1. The Architecture: Why CoroutineWorker?

While the base Worker class is available, Kotlin developers should almost always use CoroutineWorker. It allows you to use Structured Concurrency to perform asynchronous tasks.

The Power Advantage: When you use a CoroutineWorker, WorkManager automatically handles the WakeLock for you. It ensures the CPU stays awake exactly as long as your doWork() function is running and releases it the moment you return a Result.

The Implementation

@HiltWorker
class AdvancedDataSyncWorker @AssistedInject constructor(
    @Assisted appContext: Context,
    @Assisted workerParams: WorkerParameters,
    private val api: WeatherApi,
    private val dao: WeatherDao,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) : CoroutineWorker(appContext, workerParams) {

    override suspend fun doWork(): Result = withContext(ioDispatcher) {
        val cityName = inputData.getString("KEY_CITY") ?: return@withContext Result.failure()

        try {
            // 1. OS CHECK: Is the system trying to stop us?
            if (isStopped) return@withContext Result.retry()

            // 2. FORGROUND PROMOTION: 
            // If the task is critical, move to Foreground to avoid LMK (Low Memory Killer)
            // This is required for tasks longer than 10 minutes (pre-Android 14)
            // setForeground(createForegroundInfo())

            // 3. CHUNKED EXECUTION:
            // Don't do one massive operation. Break it up.
            val rawData = api.getForecast(cityName)

            // Cooperative Cancellation Check: After heavy network
            if (isStopped) return@withContext Result.retry()

            val domainModels = rawData.map { dto -> 
                // Check in loops!
                // You can save progress point if isStopped is true
                if (isStopped) return@withContext Result.retry()
                dto.toDomain() 
            }

            // 4. TRANSACTIONAL INTEGRITY:
            dao.insertData(domainModels)

            Result.success()

        } catch (e: IOException) {
            // Network failure: Signal to WorkManager to use Exponential Backoff
            if (runAttemptCount < 5) {
                Result.retry()
            } else {
                Result.failure()
            }
        } catch (e: Exception) {
            // Permanent failure (e.g., Parsing error)
            Result.failure()
        }
    } // WakeLock is released here

    private fun createForegroundInfo(): ForegroundInfo {
        // Create notification required for Foreground Services
        val notification = NotificationCompat.Builder(applicationContext, "sync_channel")
            .setContentTitle("Syncing Data...")
            .setSmallIcon(R.drawable.ic_sync)
            .build()
        return ForegroundInfo(101, notification)
    }
}
Enter fullscreen mode Exit fullscreen mode

2. The Power State Dance: WakeLocks & Radio States

To save battery, Android wants to put the Application Processor (AP) and the Cellular Radio to sleep. WorkManager manages this "dance" behind the scenes.

  • The WakeLock Lifecycle: When your job starts, WorkManager acquires a PartialWakeLock. This keeps the CPU in an "Active" state (C0) but allows the screen to stay off. If your code is inefficient and runs for 10 minutes, you are holding the CPU hostage.
  • Radio State Optimization: By setting Constraints, you tell WorkManager to wait until the Radio is already active (e.g., when the user is using another app or on Wi-Fi). This prevents your app from being the one that forces the Radio to transition from IDLE to DCH (High Power), which saves the massive energy cost of the "Radio Tail."

3. Constraints: The Negotiator

The most powerful way to avoid being "The Battery Killer" is to use Constraints. By setting constraints, you tell the OS: "I am a good citizen. Don't wake up the phone just for me; wait until these conditions are met." These are the conditions the OS must meet before your code is allowed to wake up the hardware.

fun scheduleSmartWork(context: Context) {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.UNMETERED)
        .setRequiresBatteryNotLow(true)
        .setRequiresCharging(true)
        .setRequiresDeviceIdle(true) 
        .build()

    val workRequest = PeriodicWorkRequestBuilder<AdvancedDataSyncWorker>(
        repeatInterval = 1, 
        repeatIntervalTimeUnit = TimeUnit.HOURS,
        flexTimeInterval = 15, // Run in the last 15 mins of the hour
        flexTimeIntervalUnit = TimeUnit.MINUTES
    )
    .setConstraints(constraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL, // 30s, 60s, 120s...
        WorkRequest.MIN_BACKOFF_MILLIS,
        TimeUnit.MILLISECONDS
    )
    .addTag("data_sync_tag")
    .build()

    WorkManager.getInstance(context).enqueueUniquePeriodicWork(
        "UniqueSyncName",
        ExistingPeriodicWorkPolicy.KEEP, // Don't restart the cycle if already queued
        workRequest
    )
}
Enter fullscreen mode Exit fullscreen mode

Breaking Down the Negotiator:

  • setRequiredNetworkType(NetworkType.UNMETERED): Saves the Radio. Waiting for Wi-Fi uses significantly less power than LTE/5G.
  • setRequiresBatteryNotLow(true): Prevents your app from being the one that kills the last 15% of the user's battery.
  • setRequiresCharging(true): Prevents your app from running until the user is plugged in.
  • setRequiresDeviceIdle(true): DeviceIdle means the user hasn't touched the phone in a while. This tells the OS to wait until the device is in Deep Doze and a maintenance window opens. This is the most power-efficient way to sync data. It is the best time for heavy DB maintenance or ML model training.

4. WorkManager vs. AlarmManager: The Great Debate

A common mistake is using AlarmManager for background tasks.

  • AlarmManager is for Timing. Use it only if something must happen at exactly 7:00 AM (e.g., an Alarm Clock). It is extremely expensive because it forces the AP to wake up regardless of the system state. AlarmManager is the one that says, "Do this exactly at this time".
  • WorkManager is for Reliability. It guarantees that the task will run eventually, even if the device restarts. It respects Doze and Standby Buckets by "batching" your work with other apps. WorkManager is the one that says, "Do this when you get time from such a time at your own convenience".

5. Guide: Do's and Don'ts for Kotlin Developers

DO:

  • Check isStopped: Always check for cancellation inside your loops or between long-running tasks. If the system enters Doze, it will stop your worker. If you don't check isStopped, your coroutine continues to run in a "zombie" state, wasting battery.
  • Use Expedited Jobs: If you have a task that needs to happen immediately (like sending a message), use setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST). This gives you a high-priority window even in power-saving modes.
  • Inject Dispatchers: Use Dispatchers.IO for networking/DB work within your worker to ensure you aren't blocking the worker's main thread.

DON'T:

  • Don't hold infinite loops: A Worker has a execution window (usually 10 minutes). If you exceed this, the OS will hard-kill your process.
  • Don't use Thread.sleep(): This keeps the CPU core active and burning power. Use Kotlin's delay() which is non-blocking and allows the underlying thread to be used for other tasks or enter a low-power state.
  • Don't ignore Standby Buckets: Remember that if a user hasn't opened your app in days, your Worker may be delayed by up to 24 hours. Don't design features that rely on "background real-time" updates without a Foreground Service.

If you have more of the dos and don'ts write a comment below we dive deep into it.


6. Surviving OEM Aggression (Samsung/Xiaomi)

Even with perfect Kotlin code, manufacturers like Samsung might kill your Worker. To fight this:

  1. Unique Work: Always use enqueueUniqueWork. This prevents multiple instances of your worker from stacking up and being flagged as "malicious" by OEM battery monitors.
  2. Backoff Policy: Use BackoffPolicy.EXPONENTIAL. If your sync fails because the OEM cut your network, waiting 30s, then 60s, then 120s proves to the OS that your app is "behaving" and trying to save power.

7. Testing Power States (ADB Mastery)

You cannot test battery-saving code by just hitting "Run" in Android Studio. You must simulate a dying battery and a sleeping device.

Force Doze Mode

# Unplug the phone
adb shell dumpsys battery unplug

# Enter Light Doze
adb shell dumpsys deviceidle step light

# Enter Deep Doze (No network, no alarms)
adb shell dumpsys deviceidle force-idle

# Check current status
adb shell dumpsys deviceidle get deep
Enter fullscreen mode Exit fullscreen mode

Force Standby Buckets

# Force your app into the "Rare" bucket
adb shell am set-standby-bucket com.yourapp.package rare

# Check your bucket
adb shell am get-standby-bucket com.yourapp.package
Enter fullscreen mode Exit fullscreen mode

8. Pro-Tips Checklist for the Senior Android Developer

  • Stop using Thread.sleep(): It keeps the CPU core in C-state of C0 (Active). Use delay() in your CoroutineWorker. It suspends the coroutine, allowing the thread to be reused and the CPU to potentially downclock.
  • Inject your Dispatchers: Never hardcode Dispatchers.IO. Inject them so you can swap them for StandardTestDispatcher in your unit tests.
  • The 10-Minute Rule: A Worker has a maximum execution window of 10 minutes. If your task (like a video export) takes longer, you must use setForeground().
  • Observability: Use WorkManager.getWorkInfoByIdLiveData() to show the user the progress of background tasks. Transparency reduces the chance of the user "Force Stopping" your app.
  • Chaining Work: Use beginWith(...).then(...).enqueue() to break complex logic into small, power-efficient steps. If the third step fails, the first two don't need to re-run.

The Bottom Line:

In the world of Android, your background code is a guest. If you are loud (high CPU), messy (memory leaks), or stay too long (WakeLocks), the host (OS) will kick you out. By using CoroutineWorker and respecting constraints, you become a "polite guest" that is allowed to return again and again.

Top comments (0)