Your app needs to sync data with the server every hour. Or compress images before uploading. Or clean up old cache files once a day.
You cannot use a coroutine for this. If the user closes the app, the coroutine dies. If the device restarts, the work is gone.
WorkManager guarantees execution. It schedules background tasks that survive app restarts, device reboots, and even Doze mode. Android’s system handles when to run the work — you just define what to do and under what conditions.
Prerequisites: Kotlin Tutorial: Coroutines and Tutorial #2: Hilt (for worker injection).
When to Use WorkManager
| Scenario | Solution |
|---|---|
| Quick async operation (API call) | Coroutine |
| Ongoing operation (music playback) | Foreground Service |
| Guaranteed background task | WorkManager |
| Exact-time alarm | AlarmManager |
Use WorkManager when:
- The work must complete even if the app is closed
- The work can be deferred (not time-critical)
- The work needs constraints (network, battery, etc.)
Examples: data sync, image upload, log cleanup, periodic cache refresh.
Setup
# gradle/libs.versions.toml
[versions]
workmanager = "2.11.1"
[libraries]
work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" }
work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workmanager" }
hilt-work = { group = "androidx.hilt", name = "hilt-work", version = "1.2.0" }
hilt-work-compiler = { group = "androidx.hilt", name = "hilt-compiler", version = "1.2.0" }
// build.gradle.kts
dependencies {
implementation(libs.work.runtime)
implementation(libs.hilt.work)
ksp(libs.hilt.work.compiler)
testImplementation(libs.work.testing)
}
Note: WorkManager 2.11.x requires minSdk 23 or higher.
Creating a Worker
A worker defines the background task:
class SyncWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return try {
// Your background task here
syncDataWithServer()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) {
Result.retry() // Try again later
} else {
Result.failure() // Give up after 3 attempts
}
}
}
private suspend fun syncDataWithServer() {
// API calls, database updates, etc.
}
}
Three possible results:
Result.success()— work completed successfullyResult.retry()— work failed, try again laterResult.failure()— work failed permanently
OneTimeWorkRequest
Run a task once:
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.build()
WorkManager.getInstance(context).enqueue(syncRequest)
With Constraints
Only run when conditions are met:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // Need internet
.setRequiresBatteryNotLow(true) // Battery > 20%
.setRequiresStorageNotLow(true) // Storage available
.build()
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setInitialDelay(5, TimeUnit.MINUTES) // Wait 5 min before starting
.addTag("sync") // Tag for tracking
.build()
WorkManager.getInstance(context).enqueue(syncRequest)
Available constraints:
setRequiredNetworkType()— CONNECTED, UNMETERED (WiFi), METERED, NOT_ROAMINGsetRequiresBatteryNotLow()— battery above ~20%setRequiresCharging()— device is chargingsetRequiresStorageNotLow()— storage not critically lowsetRequiresDeviceIdle()— device is idle (careful: may take hours)
Expedited Work
For time-sensitive tasks that should start immediately:
val urgentSync = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context).enqueue(urgentSync)
Expedited work runs right away, even if the app is in the background. The OutOfQuotaPolicy defines what happens if the system has no expedited quota left.
PeriodicWorkRequest
Run a task on a recurring schedule:
val periodicSync = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 1, TimeUnit.HOURS // Minimum: 15 minutes
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"periodic_sync", // Unique name
ExistingPeriodicWorkPolicy.KEEP, // Don't replace if already scheduled
periodicSync
)
Important: The minimum interval is 15 minutes. Android batches periodic work for battery efficiency, so the actual execution time may vary.
Unique Work
enqueueUniquePeriodicWork prevents duplicate schedules. Policies:
KEEP— if work with this name exists, keep the existing oneUPDATE— if work exists, update it with the new specificationCANCEL_AND_REENQUEUE— cancel existing and schedule new
For one-time work:
WorkManager.getInstance(context).enqueueUniqueWork(
"one_time_sync",
ExistingWorkPolicy.REPLACE,
syncRequest
)
Input and Output Data
Pass data to workers and get results back:
Sending Input
val inputData = workDataOf(
"task_id" to 42L,
"compress" to true,
"quality" to 85
)
val uploadRequest = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(inputData)
.build()
Reading Input in Worker
class UploadWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val taskId = inputData.getLong("task_id", 0L)
val compress = inputData.getBoolean("compress", false)
val quality = inputData.getInt("quality", 100)
// Do work with the input...
// Return output data
val outputData = workDataOf(
"upload_url" to "https://cdn.example.com/image123.jpg",
"file_size" to 1024L
)
return Result.success(outputData)
}
}
Chaining Work
Run workers in sequence or parallel:
Sequential Chain
val compressWork = OneTimeWorkRequestBuilder<CompressWorker>()
.setInputData(workDataOf("image_uri" to imageUri.toString()))
.build()
val uploadWork = OneTimeWorkRequestBuilder<UploadWorker>().build()
val notifyWork = OneTimeWorkRequestBuilder<NotifyWorker>().build()
// Compress → Upload → Notify
WorkManager.getInstance(context)
.beginWith(compressWork)
.then(uploadWork) // Gets output from compressWork as input
.then(notifyWork) // Gets output from uploadWork as input
.enqueue()
Each worker in the chain receives the output of the previous worker as its input.
Parallel Then Sequential
val downloadImage = OneTimeWorkRequestBuilder<DownloadImageWorker>().build()
val downloadMetadata = OneTimeWorkRequestBuilder<DownloadMetadataWorker>().build()
val processWork = OneTimeWorkRequestBuilder<ProcessWorker>().build()
// Download image AND metadata in parallel, then process
WorkManager.getInstance(context)
.beginWith(listOf(downloadImage, downloadMetadata)) // Parallel
.then(processWork) // Runs after BOTH finish
.enqueue()
@HiltWorker — Injecting Dependencies
Workers often need repositories, API services, or other dependencies. Use @HiltWorker:
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val taskRepository: TaskRepository, // Hilt provides this
private val userRepository: UserRepository // Hilt provides this
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return try {
taskRepository.syncWithServer()
userRepository.refreshCurrentUser()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
Hilt WorkerFactory Setup
Register the Hilt worker factory in your Application class:
@HiltAndroidApp
class MyApp : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
Disable default WorkManager initialization in AndroidManifest.xml:
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
Now WorkManager uses Hilt to create workers, and all @Inject dependencies are available.
Observing Work Status
Show work progress in your UI:
From ViewModel
@HiltViewModel
class SyncViewModel @Inject constructor(
@ApplicationContext private val context: Context
) : ViewModel() {
private val workManager = WorkManager.getInstance(context)
val syncStatus: Flow<WorkInfo.State?> = workManager
.getWorkInfosByTagFlow("sync")
.map { workInfos ->
workInfos.firstOrNull()?.state
}
fun startSync() {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.addTag("sync")
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
workManager.enqueueUniqueWork(
"sync",
ExistingWorkPolicy.REPLACE,
request
)
}
fun cancelSync() {
workManager.cancelUniqueWork("sync")
}
}
In Composable
@Composable
fun SyncButton(viewModel: SyncViewModel = hiltViewModel()) {
val syncState by viewModel.syncStatus.collectAsStateWithLifecycle(initialValue = null)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
when (syncState) {
WorkInfo.State.RUNNING -> {
CircularProgressIndicator()
Text("Syncing...")
}
WorkInfo.State.SUCCEEDED -> {
Text("Sync complete!", color = MaterialTheme.colorScheme.primary)
}
WorkInfo.State.FAILED -> {
Text("Sync failed", color = MaterialTheme.colorScheme.error)
Button(onClick = { viewModel.startSync() }) {
Text("Retry")
}
}
else -> {
Button(onClick = { viewModel.startSync() }) {
Text("Sync Now")
}
}
}
}
}
Reporting Progress
Workers can report progress back to the UI:
@HiltWorker
class UploadWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val api: UploadApi
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val files = getFilesToUpload()
files.forEachIndexed { index, file ->
api.upload(file)
setProgress(workDataOf(
"progress" to ((index + 1) * 100 / files.size)
))
}
return Result.success()
}
}
Observe progress:
workManager.getWorkInfoByIdFlow(requestId)
.map { it?.progress?.getInt("progress", 0) ?: 0 }
Practical Example: Image Compression + Upload Chain
@HiltWorker
class CompressImageWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val imageUri = inputData.getString("image_uri") ?: return Result.failure()
val quality = inputData.getInt("quality", 80)
val uri = Uri.parse(imageUri)
val bitmap = applicationContext.contentResolver
.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it)
} ?: return Result.failure()
// Compress
val compressedFile = File(applicationContext.cacheDir, "compressed_${System.currentTimeMillis()}.jpg")
FileOutputStream(compressedFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
}
return Result.success(workDataOf(
"compressed_path" to compressedFile.absolutePath
))
}
}
@HiltWorker
class UploadImageWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val uploadApi: UploadApi
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val filePath = inputData.getString("compressed_path") ?: return Result.failure()
val file = File(filePath)
return try {
val requestBody = file.readBytes().toRequestBody("image/jpeg".toMediaType())
val part = MultipartBody.Part.createFormData("image", file.name, requestBody)
val response = uploadApi.uploadImage(part)
file.delete() // Clean up temp file
Result.success(workDataOf("url" to response.url))
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
// Schedule the chain
fun uploadImage(context: Context, imageUri: Uri, quality: Int = 80) {
val compress = OneTimeWorkRequestBuilder<CompressImageWorker>()
.setInputData(workDataOf(
"image_uri" to imageUri.toString(),
"quality" to quality
))
.build()
val upload = OneTimeWorkRequestBuilder<UploadImageWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context)
.beginWith(compress)
.then(upload)
.enqueue()
}
Practical Example: Daily Sync Worker
fun scheduleDailySync(context: Context) {
val dailySync = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 24, TimeUnit.HOURS
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
).setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30, TimeUnit.SECONDS
).addTag("daily_sync")
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"daily_sync",
ExistingPeriodicWorkPolicy.KEEP,
dailySync
)
}
Call this in your Application onCreate() or when the user logs in.
Android 16 Considerations
Android 16 introduces stricter JobScheduler quotas. WorkManager adapts automatically, but be aware:
- Background work may be delayed more on battery-constrained devices
- Use expedited work for truly time-sensitive tasks
- Set appropriate constraints — don’t require idle unless necessary
Testing Workers
class SyncWorkerTest {
@Test
fun `returns success when sync succeeds`() = runTest {
val worker = TestListenableWorkerBuilder<SyncWorker>(
ApplicationProvider.getApplicationContext()
).build()
val result = worker.doWork()
assertEquals(ListenableWorker.Result.success(), result)
}
@Test
fun `returns retry on network error`() = runTest {
// For @HiltWorker, use TestWorkerBuilder with custom factory
val worker = TestListenableWorkerBuilder<SyncWorker>(
ApplicationProvider.getApplicationContext()
).setInputData(workDataOf("force_error" to true))
.build()
val result = worker.doWork()
assertEquals(ListenableWorker.Result.retry(), result)
}
}
What’s Next?
In this tutorial, you learned:
- When to use WorkManager vs coroutines vs services
- OneTimeWorkRequest and PeriodicWorkRequest
- Constraints, expedited work, and unique work
- Input/output data and chaining workers
- @HiltWorker for dependency injection
- Observing work status from Compose
- Practical examples: image upload and daily sync
Next up: Android Tutorial #10: Paging 3 — infinite scroll with Compose.
Related Articles
- Kotlin Tutorial: Coroutines — async programming
- Android Tutorial #2: Hilt — dependency injection
- Android Tutorial #6: Retrofit — API calls
This is part 9 of the Android Development Tutorial series.