Introduction
Android WorkManager defers background work to ensure reliability while respecting system resource constraints. However, WorkManager does not guarantee exact execution times — it balances work execution with battery life, device idle state, and resource availability. Periodic work has a minimum interval of 15 minutes, one-time work with delays may be deferred further by Doze mode, and work constraints (network type, charging state, storage) can prevent execution entirely. When workers appear to not run, the issue is often a combination of constraints, battery optimization, or scheduling conflicts.
Symptoms
- PeriodicWorker executes much less frequently than configured interval
- OneTimeWorkRequest with initial delay runs hours later than expected
- Worker shows ENQUEUED but never transitions to RUNNING
- Work runs on emulator but not on physical device
- Sync worker only runs when app is opened
- WorkManager returns
WORK_CONSTRAINTS_NOT_METin logs
Common Causes
- Periodic work interval less than 15 minutes (minimum enforced by WorkManager)
- Device in Doze mode deferring all background work
- Battery optimization killing WorkManager's scheduling
- Work constraints not satisfied (e.g., requires WiFi but only cellular available)
- Unique work with REPLACE policy canceling previous pending work
- Worker returns
Result.retry()repeatedly, hitting exponential backoff limits
Step-by-Step Fix
- 1.Understand WorkManager timing constraints:
- 2.```kotlin
- 3.// WRONG - minimum interval is 15 minutes, shorter intervals are rounded up
- 4.val syncWork = PeriodicWorkRequestBuilder<SyncWorker>(
- 5.5, TimeUnit.MINUTES // Will be treated as 15 minutes minimum
- 6.).build()
// CORRECT - use minimum 15 minutes for periodic work val syncWork = PeriodicWorkRequestBuilder<SyncWorker>( 15, TimeUnit.MINUTES // Minimum allowed ).build()
// For more frequent work, use foreground service instead // WorkManager is NOT designed for sub-15-minute intervals
// One-time work with flex time val syncWork = PeriodicWorkRequestBuilder<SyncWorker>( repeatInterval = 1, TimeUnit.HOURS, flexTimeInterval = 15, TimeUnit.MINUTES // Runs in last 15 min of each hour ).build() ```
- 1.Configure work constraints correctly:
- 2.```kotlin
- 3.val uploadWork = OneTimeWorkRequestBuilder<UploadWorker>()
- 4..setConstraints(
- 5.Constraints.Builder()
- 6..setRequiredNetworkType(NetworkType.CONNECTED) // Any network
- 7.// .setRequiresBatteryNotLow(true) // Don't run when battery low
- 8.// .setRequiresCharging(true) // Only run while charging
- 9.// .setRequiresDeviceIdle(true) // Only when idle (very restrictive)
- 10..setRequiresStorageNotLow(true)
- 11..build()
- 12.)
- 13..setBackoffCriteria(
- 14.BackoffPolicy.EXPONENTIAL,
- 15.10, TimeUnit.SECONDS // Start with 10s, double each retry
- 16.)
- 17..build()
WorkManager.getInstance(context).enqueueUniqueWork( "upload-work", ExistingWorkPolicy.KEEP, // Don't replace if already enqueued uploadWork )
// Debug constraints WorkManager.getInstance(context) .getWorkInfoByIdLiveData(uploadWork.id) .observe(this) { workInfo -> when (workInfo?.state) { WorkInfo.State.ENQUEUED -> Log.d("Work", "Waiting for constraints") WorkInfo.State.RUNNING -> Log.d("Work", "Worker is running") WorkInfo.State.SUCCEEDED -> Log.d("Work", "Work completed") WorkInfo.State.FAILED -> Log.d("Work", "Work failed") WorkInfo.State.BLOCKED -> Log.d("Work", "Constraints not met") WorkInfo.State.CANCELLED -> Log.d("Work", "Work cancelled") null -> Log.d("Work", "Work info not found") } } ```
- 1.Handle Doze mode and battery optimization:
- 2.```kotlin
- 3.// Check if app is battery-optimized
- 4.val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
- 5.if (pm.isIgnoringBatteryOptimizations(context.packageName)) {
- 6.Log.d("Work", "Battery optimization disabled - work runs on schedule")
- 7.} else {
- 8.Log.w("Work", "Battery optimization enabled - work may be delayed")
- 9.// Request battery optimization exemption
- 10.if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- 11.val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
- 12.data = Uri.parse("package:${context.packageName}")
- 13.}
- 14.context.startActivity(intent)
- 15.}
- 16.}
// For time-critical work, use setExpedited (API 31+) val urgentWork = OneTimeWorkRequestBuilder<UrgentSyncWorker>() .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build()
// For pre-API 31, use WorkManager's expedited work val workRequest = OneTimeWorkRequestBuilder<UrgentSyncWorker>() .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() ```
- 1.Schedule unique work to prevent duplicate scheduling:
- 2.```kotlin
- 3.// Each time app starts, this enqueues work WITHOUT duplicates
- 4.fun scheduleDailySync(context: Context) {
- 5.val syncWork = PeriodicWorkRequestBuilder<SyncWorker>(
- 6.1, TimeUnit.HOURS
- 7.).setConstraints(
- 8.Constraints.Builder()
- 9..setRequiredNetworkType(NetworkType.CONNECTED)
- 10..build()
- 11.).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork( "daily-sync", ExistingPeriodicWorkPolicy.KEEP, // Keep existing, don't reschedule syncWork ) }
// Cancel and reschedule when configuration changes fun rescheduleSync(context: Context) { WorkManager.getInstance(context) .cancelUniqueWork("daily-sync")
scheduleDailySync(context) } ```
Prevention
- Never rely on WorkManager for exact-time execution — use AlarmManager for that
- Set minimum 15-minute intervals for periodic work
- Use
setExpeditedfor time-sensitive work on API 31+ - Always check work state with WorkInfo to diagnose constraint issues
- Request battery optimization exemption for apps that need reliable background work
- Use unique work names to prevent duplicate scheduling
- Test on physical devices with Doze mode enabled, not just emulators
- Log constraint failures to understand why work is not running