Work that survives the app
A letter dropped in a postbox gets delivered even if you go home and forget about it. The post office keeps it until it can deliver, waits if the roads are closed, and tries again if nobody answers the door.
WorkManager is that postbox. You describe a piece of work (a Worker) and the conditions it needs (a WorkRequest: “only with internet”, “retry if it fails”), hand it over with enqueue, and you’re done. WorkManager saves the request in its own database, so it survives the app closing and the phone restarting, and runs the work when the conditions are met.
WorkManager persists WorkRequests in its own Room database and schedules them through JobScheduler (API 23+), honoring constraints (network type, charging, battery not low, storage not low, idle), backoff policies, and Doze/App Standby buckets. It guarantees eventual execution of enqueued work across process death and reboots, not timing. CoroutineWorker runs doWork() on Dispatchers.Default with cancellation when constraints stop being met or the 10-minute execution window ends; the result (success, retry, failure) drives rescheduling. It’s the right tool for deferrable, guaranteed work; immediate in-app work belongs in coroutines, and user-visible long-running work in a foreground service (or expedited/long-running workers).
class UploadReviewWorker(
context: Context,
params: WorkerParameters,
private val api: ReviewsApi, // injected via a WorkerFactory (Hilt: @HiltWorker)
private val dao: PendingReviewDao,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val reviewId = inputData.getString(KEY_REVIEW_ID) ?: return Result.failure()
val review = dao.byId(reviewId) ?: return Result.success() // already sent and deleted
return try {
api.post(review.toDto(), idempotencyKey = review.id)
dao.delete(reviewId)
Result.success()
} catch (e: IOException) {
Result.retry() // network trouble: try again later
} catch (e: HttpException) {
if (e.code() in 500..599) Result.retry() else Result.failure() // 4xx won't fix itself
}
}
companion object {
const val KEY_REVIEW_ID = "reviewId"
fun enqueue(context: Context, reviewId: String) {
val request = OneTimeWorkRequestBuilder<UploadReviewWorker>()
.setInputData(workDataOf(KEY_REVIEW_ID to reviewId))
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork("upload-review-$reviewId", ExistingWorkPolicy.KEEP, request)
}
}
}
// In the ViewModel: save locally first, then hand the upload to WorkManager
fun post(text: String, rating: Int) = viewModelScope.launch {
val review = PendingReview(id = UUID.randomUUID().toString(), text = text, rating = rating)
dao.insert(review)
UploadReviewWorker.enqueue(appContext, review.id)
}- Save first, then enqueue: the review is stored in the local database, and only its id goes to the worker. Input data is small (10 KB max); the database holds the real content.
- Constraints:
CONNECTEDmeans the worker waits until there’s a network. On the subway, it simply runs later. - Result:
success(done),retry(try again later, waiting longer each time: 30 s, 60 s, 2 min…), orfailure(give up; for errors that retrying can’t fix). - Unique work with
KEEP: tapping Post twice doesn’t create two uploads for the same review. - The idempotency key lets the server ignore a repeat if the app was killed after the upload but before the worker finished.
Tap the line with the bug.
class ReviewViewModel(private val api: ReviewsApi) : ViewModel() {
fun post(text: String, rating: Int) {
viewModelScope.launch {
try {
api.post(ReviewDto(text, rating))
} catch (e: IOException) {
delay(30_000)
api.post(ReviewDto(text, rating))
}
}
_events.trySend(Event.CloseScreen)
}
}Hint: The screen closes right after post() is called. What happens to viewModelScope, and what if the phone is offline for five minutes?
The upload runs in viewModelScope, and the screen closes immediately, which clears the ViewModel and cancels the coroutine. On a slow connection the request is cut off mid-flight; offline, the 30-second retry never happens. Even if the screen stayed open, a process kill (the user switches to the camera app) would lose the review, since it exists only in memory. There’s exactly one retry, no matter how long the outage.
viewModelScope is for work the screen cares about. Work that must finish after the user leaves goes to WorkManager, with the data saved to disk first:
fun post(text: String, rating: Int) = viewModelScope.launch {
val review = PendingReview(UUID.randomUUID().toString(), text, rating)
dao.insert(review) // survives process death
UploadReviewWorker.enqueue(appContext, review.id) // retried with backoff until it's sent
_events.send(Event.CloseScreen)
}Predict the output
A worker with a CONNECTED constraint is enqueued while the phone is in airplane mode. The user then restarts the phone and turns airplane mode off two hours later, without opening the app. Does the work run?
WorkManager saved the request in its database and reschedules it after a reboot, so when the network constraint is met, the system starts the app's process in the background and runs the worker, without the user opening anything. That's the guarantee WorkManager is for. (The exception: if the user force-stops the app in settings, Android cancels its scheduled work until it's opened again.)
Choosing the right tool
| Need | Tool |
|---|---|
| Work the current screen waits for | Coroutine in viewModelScope |
| Work that should outlive the screen but not the process (e.g. finish saving) | Coroutine in an app-level CoroutineScope |
| Must happen eventually, even after reboot: uploads, sync, cleanup | WorkManager |
| Should start now and finish soon, important to the user (send a message, pay) | Expedited work (setExpedited) |
| Long and user-visible: navigation, music, a big download with progress | Foreground service (typed, with a notification) or a long-running worker (setForeground) |
| Exact time, like an alarm clock | AlarmManager.setExactAndAllowWhileIdle (needs special permission) |
- WorkManager persists requests and schedules via JobScheduler with constraints, backoff and standby buckets: guaranteed eventual execution, not timing. Force-stop cancels it.
CoroutineWorker: small input (≤10 KB; pass ids, keep data in Room),success/retry/failure, 10-minute window, cancellation on constraint loss.- Unique work for dedup; idempotency keys server-side for at-least-once execution.
- viewModelScope for screen work; app scope for process-lifetime work; expedited, foreground service or AlarmManager for their specific needs.
Check this part
Why does an upload started in viewModelScope sometimes never finish?
What should a worker return when the network fails?
What does WorkManager guarantee?