WorkManager for background work: lessons from production
Background tasks are a cornerstone of modern mobile apps, yet they remain one of the trickiest parts of Android development. When a user closes your app or the device restarts, your carefully crafted background work can simply vanish—unless you build it on a solid foundation. WorkManager for background work: lessons from production is what separates apps that reliably sync data from those that lose user changes.
We've learned this the hard way across e-commerce checkout recovery, CRM contact synchronization, and ride-hailing driver status updates. This article covers what we've built and tested in production, from work persistence through constraints and chaining.
Why WorkManager beats manual solutions
Before WorkManager, teams often reached for AlarmManager, JobScheduler, or manual threading. Each had gotchas. AlarmManager doesn't respect Doze mode on modern Android versions. JobScheduler requires API 21 and has device-vendor quirks. Manual coroutines and background threads? They die when the app exits.
WorkManager abstracts these differences and guarantees that your work runs—eventually. It's the official recommendation from Google for reliable deferred tasks, and production experience confirms it handles edge cases that homegrown solutions miss.
Survives app restarts and reboots
The first guarantee WorkManager makes is persistence. When your work is enqueued, it's persisted to the device's local database. If the app is killed, the device reboots, or the user force-stops and reopens the app, WorkManager will reschedule and retry your work according to the policy you define.
In a real scenario: a user completes a payment in your e-commerce app. You enqueue a work request to confirm the order on the backend and update local inventory. The network drops, the app crashes, or the device is restarted. Hours later, when connectivity returns, WorkManager wakes up and retries. The order gets confirmed, inventory syncs, and the user sees the correct state when they reopen the app.
Here's how to define a simple one-time work request:
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
class OrderConfirmationWorker(
context: Context,
params: WorkerParameters
) : Worker(context, params) {
override fun doWork(): Result {
return try {
val orderId = inputData.getString("order_id") ?: return Result.retry()
val response = orderService.confirmOrder(orderId)
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
// Enqueue the work
val workRequest = OneTimeWorkRequestBuilder<OrderConfirmationWorker>()
.setInputData(workDataOf("order_id" to "12345"))
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"confirm_order_12345",
ExistingWorkPolicy.KEEP,
workRequest
)
Notice the enqueueUniqueWork call with ExistingWorkPolicy.KEEP. This prevents duplicate work if the same request is enqueued multiple times—essential for preventing double-charged payments or duplicate sync operations.
Constraints: network, charging, and device state
Not all work is equally urgent. Some tasks benefit from waiting until the device is charging or connected to Wi-Fi. WorkManager's constraint system lets you specify conditions under which work should run, improving battery life and user experience.
Consider a CRM contact sync. Uploading large contact photos or full sync payloads over mobile data drains battery and wastes user quota. It's better to defer this until the device is plugged in and on Wi-Fi.
import androidx.work.Constraints
import androidx.work.NetworkType
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(false)
.setRequiresStorageNotLow(true)
.build()
val syncRequest = OneTimeWorkRequestBuilder<ContactSyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
Duration.ofSeconds(30),
Duration.ofHours(1)
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"contact_sync",
ExistingWorkPolicy.REPLACE,
syncRequest
)
The backoff strategy here is important. On failure, WorkManager will wait 30 seconds before the first retry, then use exponential backoff up to 1 hour. Without this, transient network hiccups could burn through retries too quickly and mark work as failed.
Available constraints include:
- NetworkType: CONNECTED, UNMETERED, NOT_ROAMING, TEMPORARILY_UNMETERED
- requiresCharging: only run when plugged in
- requiresBatteryNotLow: skip if battery is low
- requiresStorageNotLow: ensure device has free storage
- requiresDeviceIdle: run during idle periods (API 23+)
Periodic work for sync
One-time work handles point-in-time tasks. For recurring work—like polling an API every 15 minutes, refreshing tokens, or syncing contacts—use PeriodicWorkRequest.
In a ride-hailing app, drivers' statuses need periodic refresh. Rather than a foreground service or manual timer, a periodic work request handles it cleanly:
import androidx.work.PeriodicWorkRequestBuilder
import java.util.concurrent.TimeUnit
class DriverStatusRefreshWorker(
context: Context,
params: WorkerParameters
) : Worker(context, params) {
override fun doWork(): Result {
return try {
val driverId = inputData.getString("driver_id") ?: return Result.retry()
val status = driverService.fetchStatus(driverId)
updateLocalCache(status)
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
// Enqueue periodic work: run every 15 minutes
val periodicRequest = PeriodicWorkRequestBuilder<DriverStatusRefreshWorker>(
15, TimeUnit.MINUTES
)
.setInputData(workDataOf("driver_id" to driverId))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"driver_status_$driverId",
ExistingPeriodicWorkPolicy.KEEP,
periodicRequest
)
Important: the minimum interval for periodic work is 15 minutes. WorkManager will not allow shorter periods. If you need finer-grained updates, consider using a foreground service or WebSocket for real-time data.
Also note that WorkManager's actual execution interval can drift. If you enqueue work with a 15-minute interval, it might run every 14 or 16 minutes depending on device load and constraints. Don't assume exact timing.
Chain work requests
Complex operations often require sequential steps: upload a file, wait for server processing, then download results and update local state. WorkManager's WorkContinuation API lets you chain work requests, ensuring each step completes before the next begins.
In an e-commerce app, consider order fulfillment:
class ReserveInventoryWorker(context: Context, params: WorkerParameters)
: Worker(context, params) {
override fun doWork(): Result {
return try {
val orderId = inputData.getString("order_id") ?: return Result.retry()
inventoryService.reserve(orderId)
Result.success(workDataOf("reserved" to true))
} catch (e: Exception) {
Result.retry()
}
}
}
class ProcessPaymentWorker(context: Context, params: WorkerParameters)
: Worker(context, params) {
override fun doWork(): Result {
return try {
val orderId = inputData.getString("order_id") ?: return Result.retry()
val reserved = inputData.getBoolean("reserved", false)
if (!reserved) return Result.failure()
paymentService.process(orderId)
Result.success(workDataOf("paid" to true))
} catch (e: Exception) {
Result.retry()
}
}
}
class NotifyFulfillmentWorker(context: Context, params: WorkerParameters)
: Worker(context, params) {
override fun doWork(): Result {
return try {
val orderId = inputData.getString("order_id") ?: return Result.retry()
fulfillmentService.notify(orderId)
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
// Chain them
val orderId = "order_789"
val workManager = WorkManager.getInstance(context)
workManager.beginUniqueWork(
"fulfill_$orderId",
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<ReserveInventoryWorker>()
.setInputData(workDataOf("order_id" to orderId))
.build()
)
.then(OneTimeWorkRequestBuilder<ProcessPaymentWorker>()
.setInputData(workDataOf("order_id" to orderId))
.build()
)
.then(OneTimeWorkRequestBuilder<NotifyFulfillmentWorker>()
.setInputData(workDataOf("order_id" to orderId))
.build()
)
.enqueue()
If any step in the chain returns Result.failure(), the entire chain stops. If a step returns Result.retry(), that step is retried; if it eventually succeeds, the next step runs. This ensures a predictable order of operations even across app restarts.
You can also parallelize some steps using combine() instead of then(), but the principle remains: WorkManager guarantees the execution order you define.
Common mistakes
- Treating work execution as immediate. WorkManager schedules work; it may not run for minutes or hours depending on constraints and device state. If you need instant action, use a coroutine or callback instead.
- Ignoring backoff strategy. Without exponential backoff, transient failures can exhaust retries too quickly. Always set a reasonable backoff policy.
- Enqueuing duplicate work without deduplication. Use
enqueueUniqueWorkwith appropriateExistingWorkPolicyto prevent accidental duplicates. - Assuming periodic work runs at exact intervals. Intervals can drift based on device load. Don't assume 15-minute periodic work will fire exactly every 15 minutes.
- Passing large data through work input. WorkManager persists input data to disk. Keep payloads small (under 10 KB) or use a database to store large objects and pass identifiers instead.
- Not testing with Work Manager's testing utilities. Use
WorkManagerTestInitHelperin tests to simulate work execution without waiting for actual scheduling.
Key takeaways
- WorkManager persists work across app restarts and device reboots, making it the reliable choice for deferred tasks in production.
- Constraints like network type and charging state let you defer work until optimal conditions, improving battery life and user quota usage.
- Periodic work handles recurring sync tasks, but remember the 15-minute minimum interval and that timing can drift.
- Chain work requests to enforce sequential operations across multiple steps, with automatic retry and rollback on failure.
- Always use
enqueueUniqueWorkto prevent duplicate execution, set exponential backoff to handle transient failures gracefully, and keep work input data small. - Test work locally using testing utilities; don't rely on manual testing with device reboots.






Comments
Loading comments…