SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:29
UP00:01
MOD · ARTICLE · BEST PRACTICESS/N · AX-WORKMAPUBLISHED
All Articles
Best Practices · Updated 13 MIN

WorkManager Mastery: Reliable Background Processing on Android

Build robust background work with WorkManager: chained operations, constraints, retry policies, and real-world patterns for sync, upload, and cleanup tasks.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

What problem does WorkManager solve?

Android stops background processes to protect the battery, and each release tightens the rules. Work that has to finish, an upload, a sync, a log flush, cannot live in a coroutine that dies with the process. WorkManager is the Jetpack answer, and its promise is specific: it "features a robust scheduling mechanism that lets tasks persist across app restarts and device reboots". It handles three kinds of work: immediate, long running and deferrable. The documentation is equally specific about what it is not: "WorkManager is not intended for in-process background work that can safely be terminated if the app process goes away. It is also not a general solution for all work that requires immediate execution." Two statements in the earlier version of this article did not survive a check against the sources: - It said work executes even if the user force-stops the app. The source is more careful. WorkManager offers "recovery from the application being force-stopped by the user or device", which is recovery afterwards, not execution during. The same sentence carries a warning for anyone using a custom configuration, as the Hilt setup below does: on-demand initialization "may delay some useful features of WorkManager such as automatic rescheduling of work following a crash and recovery from the application being force-stopped by the user or device" (Android). - It described a fallback to AlarmManager on old devices. That advice is obsolete. As of WorkManager 2.12.0, released on 23 September 2026, "The minSdk has been updated from API 23 to API 24" (release notes). The usual reason to reach for WorkManager is sync. The data model that makes sync safe is in offline-first architecture.

Defining Workers: OneTime and Periodic

A worker holds the work. For coroutine code extend CoroutineWorker and return a Result from doWork(): success, failure or retry. A OneTimeWorkRequest runs once. A PeriodicWorkRequest repeats, with a floor: "The minimum repeat interval that can be defined is 15 minutes" (Android). Result.retry() reschedules the work according to a backoff policy, and the defaults are worth knowing before you override them. "The default policy is EXPONENTIAL with a delay of 30 seconds", and the delay you set "can be no less than 10 seconds". Retry what the device can fix by waiting, such as a dropped connection or a server error. Fail what it cannot, such as a request the server rejected. Workers are created by WorkManager, not by you, so constructor injection needs help. With Hilt: "Inject a Worker using the @HiltWorker annotation in the class and @AssistedInject in the Worker object's constructor", and note the limit that follows, "You can use only @Singleton or unscoped bindings in Worker objects" (Android). The rest of the Hilt setup is in the Hilt guide. The earlier sample declared ArticleSyncWorker twice, once plain and once with @HiltWorker. That does not compile. There is one class, and it is the annotated one.
kotlin
@HiltWorker
class ArticleSyncWorker @AssistedInject constructor(
    @Assisted appContext: Context,
    @Assisted params: WorkerParameters,
    private val articleRepo: ArticleRepository
) : CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        val forceRefresh = inputData.getBoolean("force_refresh", false)

        return try {
            setProgress(workDataOf("progress" to 0))
            val syncCount = articleRepo.syncWithRemote(forceRefresh)
            setProgress(workDataOf("progress" to 100))

            // Output is passed to the next worker in a chain
            Result.success(workDataOf("sync_count" to syncCount))
        } catch (e: HttpException) {
            if (e.code() in 500..599) {
                Result.retry()   // Server error: waiting may fix it
            } else {
                // Client error (4xx): retrying will not help
                Result.failure(workDataOf("error" to e.message))
            }
        } catch (e: IOException) {
            Result.retry()       // Network error: retry with backoff
        }
    }
}

// Application: hand WorkManager the Hilt worker factory
@HiltAndroidApp
class MyApplication : Application(), Configuration.Provider {

    @Inject lateinit var workerFactory: HiltWorkerFactory

    // Configuration.Provider is a property in the current Kotlin API
    override val workManagerConfiguration: Configuration
        get() = Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .build()
}

Constraints: Running Work at the Right Time

Constraints state the conditions the work needs. They are not checked once at the start. The documentation: "When multiple constraints are specified, your work will run only when all the constraints are met", and "In the event that a constraint becomes unmet while your work is running, WorkManager will stop your worker. The work will then be retried when all the constraints are met." So a worker can be stopped halfway, and yours has to cope. "You should cooperatively abort any work you had in progress and release any resources your Worker is holding onto" (Android). The same page names another cause: the system stops work "if you exceed the execution deadline of 10 minutes". In a CoroutineWorker a stop arrives as cancellation, which is one more reason not to swallow it. The rules are in the coroutines deep dive. Two corrections to the old sample's comments. setRequiresBatteryNotLow(true) was annotated "Battery > 15%". The documentation gives no number: "your work will not run if the device is in low battery mode". And a flex interval is not a head start of unknown size. "The flex period begins at repeatInterval - flexInterval, and goes to the end of the interval." Unique work decides what happens when the same job is enqueued twice. For one-time work, REPLACE "cancels the existing work" and KEEP ignores the new request. Pick deliberately: REPLACE on a sync that is already uploading throws that progress away.
kotlin
// Only sync on unmetered network, while charging
val syncConstraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)   // For example, Wi-Fi
    .setRequiresCharging(true)
    .setRequiresBatteryNotLow(true)   // Not in low battery mode
    .setRequiresStorageNotLow(true)
    .build()

val syncRequest = OneTimeWorkRequestBuilder<ArticleSyncWorker>()
    .setConstraints(syncConstraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        30, TimeUnit.SECONDS          // The default, stated explicitly
    )
    .setInputData(workDataOf("force_refresh" to true))
    .addTag("article_sync")
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "article_sync",
    ExistingWorkPolicy.KEEP,          // A running sync is left alone
    syncRequest
)

// Periodic sync: every 6 hours, inside the last 30 minutes of each period
val periodicSync = PeriodicWorkRequestBuilder<ArticleSyncWorker>(
    repeatInterval = 6, TimeUnit.HOURS,
    flexInterval = 30, TimeUnit.MINUTES
).setConstraints(
    Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build()
).addTag("periodic_sync")
 .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "periodic_article_sync",
    ExistingPeriodicWorkPolicy.KEEP,
    periodicSync
)

Chaining Work: Complex Pipelines

WorkManager can chain workers into a pipeline: download, then compress, then upload. "Chains of OneTimeWorkRequest execute sequentially as long as their work completes successfully", and the output of one becomes the input of the next. The earlier version said that one failure marks the entire chain as failed. The rule is narrower. When a request returns Result.failure(), "that work request and all dependent work requests are marked as FAILED". Work that already succeeded stays succeeded. A download that finished is not undone because the upload was rejected. Parallel work has a trap of its own. When several workers feed one, their outputs are combined by an InputMerger, and the default is OverwritingInputMerger: "In case of conflicts, it overwrites the previously-set keys." Three fetch workers that each write a result key hand the merge worker one value, not three. Use ArrayCreatingInputMerger when you need them all. One more boundary: "Periodic work cannot be part of a chain or graph of work" (Android).
kotlin
// Chain: download images, compress, upload, notify
val downloadWork = OneTimeWorkRequestBuilder<DownloadImagesWorker>()
    .setConstraints(networkConstraints)
    .addTag("image_pipeline")
    .build()

val compressWork = OneTimeWorkRequestBuilder<CompressImagesWorker>()
    .addTag("image_pipeline")
    .build()

val uploadWork = OneTimeWorkRequestBuilder<UploadImagesWorker>()
    .setConstraints(networkConstraints)
    .addTag("image_pipeline")
    .build()

val notifyWork = OneTimeWorkRequestBuilder<NotifyCompletionWorker>()
    .build()

WorkManager.getInstance(context)
    .beginWith(downloadWork)        // Step 1
    .then(compressWork)             // Step 2 receives step 1's output
    .then(uploadWork)               // Step 3
    .then(notifyWork)               // Step 4
    .enqueue()

// Parallel work: fetch from several APIs, then merge
val fetchUsers = OneTimeWorkRequestBuilder<FetchUsersWorker>().build()
val fetchPosts = OneTimeWorkRequestBuilder<FetchPostsWorker>().build()
val fetchComments = OneTimeWorkRequestBuilder<FetchCommentsWorker>().build()

val mergeResults = OneTimeWorkRequestBuilder<MergeDataWorker>()
    // Keep every parent's output instead of the last one written
    .setInputMerger(ArrayCreatingInputMerger::class)
    .build()

WorkManager.getInstance(context)
    .beginWith(listOf(fetchUsers, fetchPosts, fetchComments))  // Parallel
    .then(mergeResults)
    .enqueue()

Observing Work Progress in Compose

WorkManager reports each job as a WorkInfo, and it can deliver them as a Kotlin Flow, by id, by tag or by unique work name (Android). Collect the flow in a ViewModel and the screen follows the job. Progress is something the worker publishes. In a CoroutineWorker that is setProgress(), as in the worker above, and it has a lifetime: "Progress information can only be observed and updated while the ListenableWorker is running." Once the work finishes, read the result from outputData. The earlier sample had two bugs worth naming. It observed by tag and took the last element of the list, which is not guaranteed to be the job the button started. And it read a progress value that no worker ever set. The version below observes the unique work it enqueues.
kotlin
@HiltViewModel
class SyncViewModel @Inject constructor(
    private val workManager: WorkManager
) : ViewModel() {

    // Observe the same unique work that startSync() enqueues
    val syncState: Flow<SyncUiState> = workManager
        .getWorkInfosForUniqueWorkFlow(MANUAL_SYNC)
        .map { workInfos ->
            val info = workInfos.firstOrNull()
            when (info?.state) {
                WorkInfo.State.RUNNING -> SyncUiState.Syncing(
                    progress = info.progress.getInt("progress", 0)
                )
                WorkInfo.State.SUCCEEDED -> SyncUiState.Success(
                    count = info.outputData.getInt("sync_count", 0)
                )
                WorkInfo.State.FAILED -> SyncUiState.Error(
                    message = info.outputData.getString("error") ?: "Sync failed"
                )
                WorkInfo.State.ENQUEUED -> SyncUiState.Waiting
                else -> SyncUiState.Idle
            }
        }

    fun startSync() {
        val request = OneTimeWorkRequestBuilder<ArticleSyncWorker>().build()
        workManager.enqueueUniqueWork(
            MANUAL_SYNC,
            ExistingWorkPolicy.KEEP,
            request
        )
    }

    fun cancelSync() {
        workManager.cancelUniqueWork(MANUAL_SYNC)
    }

    private companion object {
        const val MANUAL_SYNC = "manual_sync"
    }
}

sealed interface SyncUiState {
    data object Idle : SyncUiState
    data object Waiting : SyncUiState
    data class Syncing(val progress: Int) : SyncUiState
    data class Success(val count: Int) : SyncUiState
    data class Error(val message: String) : SyncUiState
}

@Composable
fun SyncButton(viewModel: SyncViewModel = hiltViewModel()) {
    val state by viewModel.syncState
        .collectAsStateWithLifecycle(SyncUiState.Idle)

    when (val current = state) {
        is SyncUiState.Syncing -> {
            LinearProgressIndicator(
                progress = { current.progress / 100f }
            )
        }
        is SyncUiState.Error -> {
            Button(onClick = { viewModel.startSync() }) {
                Text("Retry Sync")
            }
        }
        else -> {
            Button(onClick = { viewModel.startSync() }) {
                Text("Sync Now")
            }
        }
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1WorkManager persists work across app restarts and device reboots and recovers it after a force-stop. On-demand initialization may delay that recovery.
  2. 2WorkManager 2.12.0 raised minSdk to API 24. Advice about fallbacks for older devices is obsolete.
  3. 3Periodic work has a 15 minute minimum interval, and retry backoff defaults to exponential from 30 seconds with a 10 second floor.
  4. 4A constraint that becomes unmet stops a running worker. Write workers that can be stopped and run again.
  5. 5A failure fails that request and its dependents, not the work that already succeeded.
  6. 6Parallel parents overwrite each other on shared keys unless the child uses ArrayCreatingInputMerger.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

Should I use WorkManager, JobScheduler or AlarmManager?

Use WorkManager for work that must finish even if the user leaves the screen, the app exits or the device restarts, such as sync and uploads. Its documentation says it is not meant for in-process work that can safely die with the app, and it is not a general tool for work that must start immediately.

Why is my work not running immediately?

WorkManager runs work when its constraints are met and the system allows it, so the start time is not yours to set. Periodic work cannot repeat faster than every 15 minutes. A request that returned retry waits for its backoff delay, which starts at 30 seconds by default and grows exponentially.

What happens when one worker in a chain fails?

The failed request and every request that depends on it are marked FAILED, and the dependents never run. Work earlier in the chain that already succeeded keeps its result. The same rule applies to cancellation: dependents of a cancelled request are marked CANCELLED and are not executed.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA