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

ScenarioSolution
Quick async operation (API call)Coroutine
Ongoing operation (music playback)Foreground Service
Guaranteed background taskWorkManager
Exact-time alarmAlarmManager

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 successfully
  • Result.retry() — work failed, try again later
  • Result.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_ROAMING
  • setRequiresBatteryNotLow() — battery above ~20%
  • setRequiresCharging() — device is charging
  • setRequiresStorageNotLow() — storage not critically low
  • setRequiresDeviceIdle() — 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 one
  • UPDATE — if work exists, update it with the new specification
  • CANCEL_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.



This is part 9 of the Android Development Tutorial series.