WorkManager is the Jetpack library for guaranteed background execution — work that must eventually complete even if the app exits, the device restarts, or the process is killed. It is the right tool for a specific class of tasks. Understanding when to use it — and when not to — is as important as knowing its API.
What WorkManager Is (and Isn't)
WorkManager guarantees execution by persisting work to a database. If the process dies, work is rescheduled when the app restarts. It uses JobScheduler on API 23+ internally, falling back to AlarmManager on older devices.
Use WorkManager for:
- Uploading analytics/logs when the device gets a network connection
- Syncing offline changes to the server
- Compressing and uploading photos in the background
- Sending a notification at a scheduled time even if the app is closed
Don't use WorkManager for:
- Work that must run at an exact millisecond (use
AlarmManager.setExact) - Work that must run right now while the user is watching (use coroutines)
- UI-reactive work triggered by user interaction (use coroutines)
- Periodic polling at sub-15-minute intervals (OS won't allow it)
Your First Worker
class SyncWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) { // CoroutineWorker runs on Dispatchers.IO by default
override suspend fun doWork(): Result {
return try {
val pendingItems = localDb.pendingChanges()
remoteApi.sync(pendingItems)
localDb.markSynced(pendingItems)
Result.success()
} catch (e: IOException) {
if (runAttemptCount < 3) {
Result.retry() // WorkManager will retry with exponential backoff
} else {
Result.failure(
workDataOf("error" to e.message)
)
}
}
}
}
Return values:
Result.success()— work is done; won't run again (for OneTimeWork)Result.retry()— failed; try again later with backoffResult.failure()— permanently failed; don't retry
OneTimeWorkRequest vs PeriodicWorkRequest
OneTimeWorkRequest
Runs once. Can be chained, cancelled, queried.
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.setInputData(workDataOf("userId" to userId))
.build()
WorkManager.getInstance(context).enqueue(syncRequest)
PeriodicWorkRequest
Repeats at a minimum interval of 15 minutes (OS-enforced).
val periodicSync = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 1,
repeatIntervalTimeUnit = TimeUnit.HOURS
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
"hourly-sync",
ExistingPeriodicWorkPolicy.KEEP, // don't replace if already queued
periodicSync
)
ExistingPeriodicWorkPolicy.KEEP — preserve the existing schedule.
ExistingPeriodicWorkPolicy.REPLACE — cancel and re-enqueue (resets the timer).
ExistingPeriodicWorkPolicy.UPDATE — update constraints/data without resetting timer.
Constraints
Constraints let WorkManager wait for the right conditions before running:
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // any network
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only
.setRequiresBatteryNotLow(true) // > ~15% battery
.setRequiresCharging(true) // plugged in
.setRequiresStorageNotLow(true) // disk not full
.setRequiresDeviceIdle(true) // API 23+; device idle
.build()
WorkManager monitors these conditions continuously. If constraints are met, work runs. If a constraint is lost mid-work (e.g., network drops), the work is interrupted and rescheduled.
Passing Data In and Out
// Input data
val request = OneTimeWorkRequestBuilder<CompressWorker>()
.setInputData(workDataOf(
"imagePath" to "/storage/image.jpg",
"quality" to 80
))
.build()
// In the Worker
class CompressWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val path = inputData.getString("imagePath") ?: return Result.failure()
val quality = inputData.getInt("quality", 85)
val outputPath = compress(path, quality)
// Output data passed to chained workers or observers
return Result.success(workDataOf("outputPath" to outputPath))
}
}
// Observe output
WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(request.id)
.observe(this) { info ->
if (info?.state == WorkInfo.State.SUCCEEDED) {
val path = info.outputData.getString("outputPath")
displayResult(path)
}
}
Data values are limited to 10 KB total — pass identifiers (IDs, paths), not large payloads.
Chaining Work
val download = OneTimeWorkRequestBuilder<DownloadWorker>().build()
val compress = OneTimeWorkRequestBuilder<CompressWorker>().build()
val upload = OneTimeWorkRequestBuilder<UploadWorker>().build()
WorkManager.getInstance(context)
.beginWith(download)
.then(compress) // runs after download succeeds; receives download's output
.then(upload) // runs after compress succeeds
.enqueue()
Parallel then sequential:
val thumbnails = OneTimeWorkRequestBuilder<ThumbnailWorker>().build()
val metadata = OneTimeWorkRequestBuilder<MetadataWorker>().build()
val save = OneTimeWorkRequestBuilder<SaveWorker>().build()
WorkManager.getInstance(context)
.beginWith(listOf(thumbnails, metadata)) // both run in parallel
.then(save) // runs after BOTH complete
.enqueue()
Unique Work: Preventing Duplicates
Use enqueueUniqueWork to ensure only one instance of a task runs at a time:
WorkManager.getInstance(context)
.enqueueUniqueWork(
"photo-upload",
ExistingWorkPolicy.APPEND_OR_REPLACE, // cancel existing, start fresh
uploadRequest
)
Policies:
KEEP— ignore new request if one is already pending/runningREPLACE— cancel existing, enqueue newAPPEND— chain new work after existingAPPEND_OR_REPLACE— append if running, replace if failed/cancelled
Reporting Progress
For long-running workers, report progress back to the UI:
class UploadWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val files = inputData.getStringArray("files") ?: return Result.failure()
files.forEachIndexed { index, file ->
upload(file)
val progress = ((index + 1).toFloat() / files.size * 100).toInt()
setProgress(workDataOf("progress" to progress))
}
return Result.success()
}
}
// Observe in UI
WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(request.id)
.observe(this) { info ->
val progress = info?.progress?.getInt("progress", 0) ?: 0
progressBar.progress = progress
}
Key Takeaways
| Concept | Rule |
|---|---|
| When to use WorkManager | Guaranteed execution, constraints, process-death-safe |
| When NOT to use | Exact timing, immediate UI work, < 15 min intervals |
Result.retry() | Triggers exponential backoff; respect runAttemptCount |
PeriodicWork min interval | 15 minutes (OS-enforced) |
| Data limit | 10 KB per input/output payload |
| Unique work | Prevents duplicate queuing of the same logical task |
| Chaining | Output of previous worker becomes input of next |
| Progress | setProgress() in worker; observe via WorkInfo.progress |