WorkManager is built on top of JobScheduler (and AlarmManager for very old devices). For most apps, WorkManager is the right choice and you never need to touch JobScheduler directly. But understanding when each layer is appropriate matters for advanced use cases.
The Background Work API Stack
App code
└── WorkManager ← Use this for almost everything
└── JobScheduler ← WorkManager uses this on API 23+
└── AlarmManager (fallback for pre-23, via WorkManager)
WorkManager: Use for 99% of Background Work
WorkManager's advantages over raw JobScheduler:
| Feature | WorkManager | JobScheduler |
|---|---|---|
| API level | 14+ | 21+ |
| Kotlin/Coroutines support | Built-in (CoroutineWorker) | No |
| Chaining jobs | Yes | No |
| Observing state | WorkInfo LiveData/Flow | No |
| Unique work | Yes | No (manual) |
| Testing | Excellent test support | Difficult |
| Backoff policy | Built-in exponential | Manual |
When to use WorkManager:
- Sync operations that need to survive app death
- File uploads/downloads
- Database maintenance (cleanup, migration)
- Analytics batch uploads
- Any deferrable background work
JobScheduler: When to Use Directly
Only reach for JobScheduler directly when:
- You need exact timing at the system level — WorkManager adds a small indeterminate delay; JobScheduler still has a minimum ~15 minute window for periodic.
- You're building an SDK — WorkManager adds significant library weight (~1 MB); SDKs often use JobScheduler to avoid inflating consumers' APK.
- Very specific system triggers — some triggers are available in JobScheduler but not exposed by WorkManager.
// Direct JobScheduler usage (for SDK authors)
val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
val jobInfo = JobInfo.Builder(JOB_ID, ComponentName(context, MySyncJobService::class.java))
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
.setRequiresBatteryNotLow(true)
.setPersisted(true) // survive reboot
.setBackoffCriteria(30_000, JobInfo.BACKOFF_POLICY_EXPONENTIAL)
.build()
jobScheduler.schedule(jobInfo)
class MySyncJobService : JobService() {
private var syncJob: Job? = null
override fun onStartJob(params: JobParameters): Boolean {
syncJob = CoroutineScope(Dispatchers.IO).launch {
try {
performSync()
jobFinished(params, false) // false = don't reschedule
} catch (e: IOException) {
jobFinished(params, true) // true = reschedule (retry)
}
}
return true // true = still processing (async)
}
override fun onStopJob(params: JobParameters): Boolean {
syncJob?.cancel()
return true // true = reschedule this job
}
}
AlarmManager: Almost Never Use Directly
AlarmManager schedules work at an exact time, even if the device is in Doze. It's the most battery-expensive option because it prevents the device from sleeping.
The only valid use case: User-visible alarms (alarm clock apps, reminder apps) that must fire at an exact time regardless of device state.
// Exact alarm (requires SCHEDULE_EXACT_ALARM permission, API 31+)
val alarmManager = getSystemService(AlarmManager::class.java)
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
)
Don't use AlarmManager for: data syncs, analytics, background polls. Use WorkManager instead.
Decision Flowchart
Does it need to fire at an exact clock time?
└── Yes: AlarmManager (user-visible alarms only)
└── No: Does your code run in an SDK?
└── Yes + size matters: JobScheduler
└── No: WorkManager
Key Takeaways
| API | When to use |
|---|---|
| WorkManager | All deferrable background work; almost everything |
| JobScheduler | SDK authors who can't afford WorkManager's size |
| AlarmManager | Exact-time alarms for user-facing alarm apps only |