WorkManager Background Tasks
Introduction
Background processing is a cornerstone of modern Android apps, yet it comes with inherent risks: processes die, network drops, and battery thresholds are crossed. Historically, developers juggled JobScheduler, FirebaseJobDispatcher, and raw threads, each with their own quirks and compatibility constraints. WorkManager resolves this fragmentation by providing a single, unified API for deferrable, guaranteed background work that survives app restarts and device reboots. It abstracts away the underlying execution engine—choosing between Jobscheduler, AlarmManager, or a foreground service—based on the API level and the nature of the request. This tutorial walks through practical, production‑ready usage of WorkManager in Kotlin, covering scheduling, constraints, chaining, and observation.
Adding WorkManager to Your Project
Before writing any worker, you must declare the WorkManager dependency. The library is published under androidx.work:work-runtime-ktx, which includes the Kotlin coroutines extension and automatically pulls in the core runtime. For projects that also need the GCM backend on very old devices, work-gcm can be added, but most modern apps only need the standard runtime. Below is the Gradle snippet you place in your module‑level build.gradle.kts (or .gradle) file.
dependencies {
implementation("androidx.work:work-runtime-ktx:2.9.1")
}
The version number 2.9.1 is the latest stable release as of mid‑2024; always check the AndroidX release tracker for newer patches. Adding this line makes the WorkManager, Worker, OneTimeWorkRequest, and PeriodicWorkRequest classes available to your code. No manifest changes are required because WorkManager registers itself via the WorkManagerInitializer content provider, which is merged automatically when the dependency is present.
Creating a Custom Worker
WorkManager executes your logic inside subclasses of Worker (or the coroutine‑friendly CoroutineWorker). A worker must override doWork(), which runs on a background thread and must return a Result indicating success, failure, or retry guidance. The following example defines a simple UploadWorker that reads a URL from its input data, simulates an upload, and returns Result.success() with output data.
import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.Data
class UploadWorker(
context: Context,
params: WorkerParameters
) : Worker(context, params) {
override fun doWork(): Result {
val url = inputData.getString("url")
?: return Result.failure()
Log.d(TAG, "Uploading: $url")
// Simulate network call
if (System.currentTimeMillis() % 2 == 0L) {
val output = Data.Builder()
.putString("responseCode", "200")
.build()
return Result.success(output)
}
return Result.retry()
}
companion object {
private const val TAG = "UploadWorker"
}
}
Notice how the worker extracts parameters via inputData and builds a new Data object for Result.success(). Returning Result.retry() tells WorkManager to schedule another attempt according to the backoff policy you defined when building the request. If doWork() throws an unhandled exception, WorkManager treats it as a failure. Always keep the method lightweight—complex network logic should be delegated to a repository or use case, not placed directly inside the worker.
Scheduling One‑Time Work
Most background tasks are fire‑and‑forget: a one‑time upload, a cache cleanup, a metric push. WorkManager represents these with OneTimeWorkRequest. The builder fluent API lets you attach input data, tags, and a backoff policy before calling enqueue() or enqueueUnique(). The snippet below enqueues a one‑time upload work with a custom input payload and a tag for later querying.
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Data
import java.util.concurrent.TimeUnit
val uploadRequest = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(
Data.Builder()
.putString("url", "https://example.com/image.png")
.build()
)
.addTag("upload-tag")
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
10, TimeUnit.SECONDS
)
.build()
WorkManager.getInstance(applicationContext)
.enqueue(uploadRequest)
WorkManager immediately persists the request to an internal SQLite database, so even if the app crashes before execution, the work will run later when constraints allow. The addTag() call groups the work for bulk operations like cancelByTag() or getWorkInfosByTag(). Using enqueueUniqueWork() instead of enqueue() prevents duplicate requests with the same name; it requires an ExistingWorkPolicy (replace, keep, or append) that dictates how conflicts are resolved.
Scheduling Periodic Work
When a task must repeat at regular intervals—syncing contacts, polling for updates, uploading collected telemetry—PeriodicWorkRequest is the appropriate choice. It guarantees a minimum interval, but actual execution may be delayed to conserve battery and to respect Doze mode. The following example schedules a periodic cleanup worker that runs at least every 12 hours.
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
val cleanupRequest = PeriodicWorkRequestBuilder<CleanupWorker>(
12, TimeUnit.HOURS
)
.addTag("cleanup-tag")
.build()
WorkManager.getInstance(applicationContext)
.enqueue(cleanupRequest)
WorkManager will attempt to honor the 12‑hour interval, but system constraints (Doze, app standby, low battery) may push the execution later. If the worker returns Result.retry(), the retry backoff is applied within the periodic cycle. Note that the minimum interval is 15 minutes on Android 8.0+; requesting a shorter duration triggers an IllegalArgumentException. For more aggressive timing, consider a foreground service or JobScheduler directly, but those require deeper lifecycle management.
Applying Constraints
Real‑world work often depends on device state: you might only want to upload when the device is charging and connected to Wi‑Fi. Constraint objects let you declaratively specify these requirements. WorkManager combines them with a logical AND—all constraints must be satisfied before the worker starts. Here is how you attach constraints to a periodic sync request.
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
val syncConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(true)
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
1, TimeUnit.HOURS
)
.setConstraints(syncConstraints)
.addTag("sync-tag")
.build()
WorkManager.getInstance(applicationContext)
.enqueue(syncRequest)
The NetworkType enum offers granular control: NOT_REQUIRED, UNMETERED, CONNECTED, etc. Setting setRequiresBatteryNotLow(true) prevents the work from running when the battery is under 5% (or the device‑defined threshold). If constraints become unsatisfied after the work has started, the worker continues until it finishes or is cancelled; constraints are only checked at enqueue time and at retry decisions. This design avoids interrupting long‑running operations mid‑flight.
Chaining Work with WorkContinuation
Complex workflows often require sequential or parallel stages. WorkManager’s WorkContinuation API lets you chain requests using then(). The output of one worker can be fed as input to the next via Data. The following example chains an upload worker followed by a notification worker, ensuring the notification only fires after a successful upload.
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Data
import androidx.work.Constraints
import androidx.work.NetworkType
val uploadReq = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(
Data.Builder()
.putString("url", "https://example.com/file.pdf")
.build()
)
.build()
val notifyReq = OneTimeWorkRequestBuilder<NotificationWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
.build()
)
.build()
WorkManager.getInstance(applicationContext)
.beginWith(uploadReq)
.then(notifyReq)
.enqueue()
then() creates a dependency: notifyReq will not start until uploadReq completes with Result.success(). If the upload fails, the notification is skipped. You can also branch with beginWith(listOf(...)) and merge later, enabling fan‑out/fan‑in patterns. When chaining, each worker’s output data is automatically passed as input to the next worker—no manual plumbing is needed. This declarative approach keeps your scheduling code readable and lets WorkManager optimize execution order and resource usage.
Observing Work Status
Sometimes your UI needs to react to work completion: show a success snackbar, update a progress bar, or enable a button. WorkManager exposes WorkInfo through LiveData (or Flow in Kotlin). By observing a work request’s UID or tag, you receive live updates whenever the work state changes. The example below observes a unique work name and updates a TextView accordingly.
import androidx.lifecycle.LiveData
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.UniqueWorkName
val workName = "sync-work-name"
val workInfoLiveData: LiveData<List<WorkInfo>> =
WorkManager.getInstance(applicationContext)
.getWorkInfosForUniqueWorkLiveData(workName)
workInfoLiveData.observe(viewLifecycleOwner) { workInfos ->
val workInfo = workInfos?.firstOrNull() ?: return@observe
when (workInfo.state) {
WorkInfo.State.SUCCEEDED -> binding.statusText.text = "Done"
WorkInfo.State.FAILED -> binding.statusText.text = "Failed"
WorkInfo.State.RUNNING -> binding.statusText.text = "In progress"
else -> binding.statusText.text = "Pending"
}
}
The observer receives a list because multiple runs may exist (e.g., after a retry). Using getWorkInfosForUniqueWorkLiveData() is preferred over getWorkInfoByIdLiveData() because the UID remains stable across app restarts. Always scope the LiveData observation to a LifecycleOwner to prevent memory leaks. If you need reactive streams instead of LiveData, replace getWorkInfosForUniqueWorkLiveData() with getWorkInfosForUniqueWorkFlow(), which returns a Flow<List<WorkInfo>> that can be collected with coroutines.
Common Pitfalls
Even seasoned developers trip over WorkManager’s subtleties. First, never call get() or await() on a worker—that blocks the main thread and defeats the purpose of asynchronous scheduling. Second, avoid storing large payloads in Data; WorkManager serializes input data to JSON, and oversized bundles can cause TransactionTooLargeException. Third, remember that WorkManager does not guarantee immediate execution; it is designed for deferrable work. If you need foreground responsiveness, use a ForegroundService. Fourth, do not mix enqueue() and enqueueUniqueWork() for the same conceptual task, as duplicates will accumulate. Finally, test workers in isolation using TestListenableWorkerBuilder from androidx.work.testing—the rule applies the same constraints and simulates execution without touching the system scheduler.
Conclusion
WorkManager brings reliability, simplicity, and flexibility to Android background tasks. By defining workers as pure Kotlin classes, attaching declarative constraints, chaining requests with WorkContinuation, and observing state through LiveData or Flow, you can build robust background pipelines that respect battery, network, and user preferences. The library handles persistence, retries, and system‑level optimizations so you can focus on business logic. As your app grows, revisit the WorkManager codelabs and source code; the API continues to evolve with performance improvements and new features like ListenableWorker and structured concurrency support. With the patterns outlined here, you’re equipped to tackle everything from one‑off uploads to complex, multi‑stage sync workflows.