Android WorkManager Scheduling: Constraints, Doze, Expedited Work, and Task Chains

During a performance-optimization effort, I ran into a problem that was confusing for a long time: a network sync task was scheduled with WorkManager and constrained to run when the network was available, but on some devices it never fired. Logs showed that the task was definitely in the queue, and its state was ENQUEUED, but it simply did not run.

The root cause turned out to be aggressive battery-saving behavior on that device batch. Doze mode fully cut off network access, WorkManager’s internal constraint check kept returning false, and the task just sat in the database.

This article starts from that scenario and breaks down how WorkManager scheduling actually works.

WorkManager’s role and lower-level scheduler selection

WorkManager is not for “do something immediately.” It is for “make sure this work eventually finishes when the right conditions are met.” That guarantee is what separates it from Handler or a plain ThreadPool.

At the implementation layer, WorkManager chooses the underlying scheduler based on API level:

  • API 23+: prefer JobScheduler
  • Below API 23, now mostly historical: fall back to AlarmManager + BroadcastReceiver

JobScheduler is a system-level service. Even if the app process is killed, task metadata is preserved by the system and can continue after restart. WorkManager adds a Room database on top of that, persisting task state, constraints, input and output data, and building a second layer of reliability.

// Simplified internal scheduler selection logic from WorkManagerImpl
val scheduler = when {
    Build.VERSION.SDK_INT >= 23 -> SystemJobScheduler(context, ...)
    else -> SystemAlarmScheduler(context, ...)
}

Each WorkRequest maps to one WorkSpec record in the database. That record contains the complete task description: Worker class name, constraint set, input data, retry policy, and chain relationship. The scheduler schedules WorkSpec records, not Worker objects themselves.

How the Constraint scheduling engine works

Constraints are one of WorkManager’s most commonly used features, but many developers only know how to set setRequiredNetworkType and do not know how constraints are detected and triggered.

WorkManager constraint detection is built around the ConstraintTracker system. Each constraint type has its own Tracker:

Constraint typeTracker implementationListening mechanism
NetworkTypeNetworkStateTrackerConnectivityManager NetworkCallback
BatteryNotLowBatteryNotLowTrackerBroadcastReceiver (ACTION_BATTERY_CHANGED)
StorageNotLowStorageNotLowTrackerBroadcastReceiver (ACTION_DEVICE_STORAGE_OK/LOW)
RequiresChargingBatteryChargingTrackerBroadcastReceiver
RequiresDeviceIdleDeviceIdleTrackerBroadcastReceiver (ACTION_DEVICE_IDLE_MODE_CHANGED)

These Trackers are aggregated by WorkConstraintsTracker. Only when every constraint is satisfied does it trigger onAllConstraintsMet() and dispatch the task.

// Typical constraint setup
val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .setRequiresStorageNotLow(true)
    .build()

val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(constraints)
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
    .build()

Constraint satisfaction is checked dynamically in real time, not only once when the task is enqueued. If the network is unavailable at enqueue time, WorkManager keeps listening for network changes and reevaluates once the network returns.

When constraints are evaluated

JobScheduler provides native support for constraints such as setRequiredNetworkType. On API 23 and later, WorkManager translates constraints directly into JobScheduler constraints and lets the system layer evaluate them, which is more efficient and more power-friendly.

JobScheduler’s constraint granularity is limited. For finer-grained constraints such as NetworkType.UNMETERED, WorkManager may perform a second app-layer validation after JobScheduler wakes the job. This has a practical consequence: a task being woken does not mean every constraint is satisfied. It may still be blocked during the second validation phase.

Doze mode and WorkManager compatibility

Doze mode, introduced in Android 6.0, is one of the biggest obstacles for background tasks. After a device is idle and the screen has been off for a while, the system periodically enters deep sleep. During that time:

  • Network access is fully blocked
  • WakeLock does not work
  • Regular AlarmManager alarms are deferred
  • JobScheduler jobs are deferred until a Maintenance Window

Maintenance Window is the key concept for understanding Doze compatibility. The system does not refuse to run tasks forever. During each Doze cycle, it opens a short window, from a few seconds to tens of seconds, where network and CPU briefly recover and accumulated jobs can run together.

WorkManager runs on JobScheduler, so it naturally follows this mechanism. A task with a network constraint waits until a Maintenance Window while in Doze. In that window, “network available” briefly becomes true.

// In Worker network requests, account for the limited Doze window
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            // Use a reasonable timeout to avoid occupying the whole Maintenance Window
            withTimeout(20_000) {
                syncData()
            }
            Result.success()
        } catch (e: TimeoutCancellationException) {
            // Timeout is not a hard failure; continue in the next window
            Result.retry()
        } catch (e: Exception) {
            Result.failure()
        }
    }
}

Choosing between setExpedited and foreground service

If a task is time-sensitive and cannot wait for a Doze window, there are two available paths.

Path 1: setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)

Expedited Work was introduced in Android 12. The system gives it higher priority, and it has some support even during Doze. But each app has quota limits, and overuse will cause downgrade.

val urgentRequest = OneTimeWorkRequestBuilder<UrgentWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

Path 2: bind a foreground service

WorkManager supports promoting a Worker to a foreground service, and the system applies looser restrictions to foreground services:

override suspend fun getForegroundInfo(): ForegroundInfo {
    val notification = buildNotification()
    return ForegroundInfo(NOTIFICATION_ID, notification)
}

In real projects, I usually prefer Expedited Work because a foreground service must keep showing a notification, which is intrusive for users. Foreground service is worth it only for genuinely long-running, user-visible tasks such as file upload progress.

Task chains and WorkContinuation execution model

WorkManager supports chain orchestration, which is where it is much stronger than the native JobScheduler API.

// Serial chain
WorkManager.getInstance(context)
    .beginWith(fetchDataWork)
    .then(processDataWork)
    .then(uploadResultWork)
    .enqueue()

// Parallel + join
val parallel = WorkManager.getInstance(context)
    .beginWith(listOf(fetchUserWork, fetchProductWork))
    .then(mergeWork) // Runs after both parallel tasks finish
    .enqueue()

Task-chain dependencies are also persisted in the database. A WorkSpec record contains the list of prerequisite task IDs. Whenever a task completes, WorkManager queries every task that depends on it, checks whether all prerequisites have finished, and if so moves the dependent task into the scheduling queue.

One trap I have hit: if one task in the chain returns Result.failure(), the default behavior is to stop the whole chain. Later tasks become CANCELLED. If you want the chain to continue after a task fails, return Result.success() with an error marker in OutputData and let downstream tasks decide what to do.

// Pass soft-failure state through OutputData
override suspend fun doWork(): Result {
    return try {
        val data = fetchOptionalData()
        Result.success(workDataOf("data" to data))
    } catch (e: Exception) {
        // Do not break the chain; pass the error downstream
        Result.success(workDataOf("error" to e.message))
    }
}

Debugging and state observation

WorkManager exposes LiveData for task-state observation, which is useful for progress UI:

WorkManager.getInstance(context)
    .getWorkInfoByIdLiveData(syncRequest.id)
    .observe(lifecycleOwner) { workInfo ->
        when (workInfo?.state) {
            WorkInfo.State.RUNNING -> showProgress()
            WorkInfo.State.SUCCEEDED -> showSuccess()
            WorkInfo.State.FAILED -> showError()
            else -> Unit
        }
    }

For Doze-related debugging, ADB commands are more direct than logs:

# Force Doze mode for testing
adb shell dumpsys deviceidle force-idle

# Inspect current WorkManager task state through JobScheduler
adb shell dumpsys jobscheduler | grep <your.package.name>

# Exit forced Doze test mode
adb shell dumpsys deviceidle unforce

In real projects, I have found that dumpsys jobscheduler shows constraint satisfaction directly and is much faster than adding logs in code. The constraint list after Ready: false tells you exactly which condition is not satisfied, so locating a Doze-related issue may take a single command.


Back to the original issue: the root cause on that device batch was vendor-specific battery saving, which compressed the Maintenance Window to an extremely short duration. With the network constraint present, the window ended before network access had fully recovered, so the task was skipped. The final fix was to switch the task to Expedited Work, which was the better strategy for that sync workload.

A few practical rules you can apply directly:

  1. Keep constraints conservative: add only the constraints that are truly necessary. Every extra constraint adds another gate, and in Doze those gates stack up until a task becomes very hard to schedule.
  2. Always set timeouts inside Worker: Doze Maintenance Windows are short. Without a timeout, one Worker can occupy the whole window and starve other tasks. withTimeout plus Result.retry() is the standard pattern.
  3. Debug with dumpsys jobscheduler: compared with code-level logging, this command gives you the system’s view of constraint state and usually cuts diagnosis time by an order of magnitude.