Skip to content
Commitwise
Menu
Android Development

WorkManager and background work

Run work that must finish even if the user leaves the app or the phone restarts, with WorkManager's constraints, retries with backoff, unique and chained work, periodic jobs and expedited work, and know when to use a coroutine, a foreground service or nothing at all.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The user writes a review, taps Post, and puts the phone in their pocket. The app was uploading the review in a coroutine in the ViewModel; the screen closed, the ViewModel was cleared, the coroutine was cancelled, and the review never arrived. Another app syncs every 15 minutes with a Handler loop and wonders why Android kills it and users complain about battery.

Android doesn’t let apps run whenever they like in the background, for good reasons. WorkManager is the API for work that must happen eventually, even after the app is closed or the phone restarts, while respecting the system’s battery rules.

New to some of these? Open what you need, or skip ahead.

Basics:What is a coroutine?

A coroutine is a Kotlin function that can suspend: pause while it waits (for the network, a database, a timer) without blocking the thread, and continue later. Coroutines started in viewModelScope are cancelled when the ViewModel is cleared, for example when the user leaves the screen. The Coroutines and Flow tutorial covers them.

Basics:What happens to an app in the background?

When the user leaves your app, Android may stop its process at any time to free memory, without warning, and it limits what background apps may do to save battery (Doze mode, App Standby). Anything running only in memory, like a coroutine, disappears with the process.

Part Not started

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: CONNECTED means 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…), or failure (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.
Spot the bugReviews that never arrive

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)
    }
}

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?

Choosing the right tool

NeedTool
Work the current screen waits forCoroutine 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, cleanupWorkManager
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 progressForeground service (typed, with a notification) or a long-running worker (setForeground)
Exact time, like an alarm clockAlarmManager.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

  1. Why does an upload started in viewModelScope sometimes never finish?

  2. What should a worker return when the network fails?

  3. What does WorkManager guarantee?

A Worker holds the work; a WorkRequest says when it may run (constraints like network or charging), how to retry, and what input it gets. WorkManager persists the request, so it survives the app closing and the device rebooting, and runs it when the constraints are met.

Part Not started

Periodic work, chains and progress

Some work repeats: refresh the news every few hours, clean up old files once a day. Some work happens in steps: compress a photo, then upload it, then tell the server. WorkManager handles both, and lets the screen show what’s happening.

Periodic work has a 15-minute minimum interval and an optional flex window; execution drifts with Doze and standby buckets, so design for “roughly every N”. Enqueue periodic work with enqueueUniquePeriodicWork and KEEP (or UPDATE to change constraints without resetting the schedule), typically from Application.onCreate or after login. Chains (beginWith(a).then(b), parallel fan-in with lists and InputMerger) propagate failure: a failed or cancelled step fails its dependents. Progress (setProgress) and state are observable as Flow<WorkInfo> by id, unique name or tag.

// Daily sync, scheduled once; KEEP leaves an existing schedule alone
fun scheduleDailySync(context: Context) {
    val request = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.DAYS)
        .setConstraints(Constraints(
            requiredNetworkType = NetworkType.UNMETERED,     // Wi-Fi only
            requiresBatteryNotLow = true,
        ))
        .build()
    WorkManager.getInstance(context)
        .enqueueUniquePeriodicWork("daily-sync", ExistingPeriodicWorkPolicy.KEEP, request)
}

// A chain: compress, then upload, then confirm. Each step's output is the next one's input.
fun uploadPhoto(context: Context, photoUri: Uri) {
    val compress = OneTimeWorkRequestBuilder<CompressWorker>()
        .setInputData(workDataOf("uri" to photoUri.toString()))
        .build()
    val upload = OneTimeWorkRequestBuilder<UploadWorker>()
        .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
        .build()
    val confirm = OneTimeWorkRequestBuilder<ConfirmWorker>().build()

    WorkManager.getInstance(context)
        .beginUniqueWork("photo-${photoUri.lastPathSegment}", ExistingWorkPolicy.KEEP, compress)
        .then(upload)
        .then(confirm)
        .enqueue()
}

// The screen observes progress
val uploadState: Flow<WorkInfo?> = WorkManager.getInstance(context)
    .getWorkInfosForUniqueWorkFlow("photo-$id")
    .map { infos -> infos.lastOrNull() }
  • Periodic work runs at most every 15 minutes, and Android decides the exact moment, so “daily” means “about once a day”. UNMETERED waits for Wi-Fi, requiresBatteryNotLow skips runs on a low battery.
  • KEEP makes scheduleDailySync safe to call on every app start: it doesn’t create a second schedule.
  • Chains: upload starts only after compress succeeds, and gets its output (the compressed file’s path) as input. If a step fails, the steps after it don’t run.
  • Progress: a worker can call setProgress(workDataOf("percent" to 40)), and the screen observes it as a Flow.

Production puzzles

Spot the bugSync running ten times an hour

Tap the line with the bug.

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        val sync = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS).build()
        WorkManager.getInstance(this).enqueue(sync)
    }
}

Testing workers

work-testing provides TestListenableWorkerBuilder to run a worker’s doWork() directly with fake dependencies, and WorkManagerTestInitHelper with a TestDriver to mark constraints as met and fast-forward periodic delays in instrumented tests, so chains and constraints can be tested without waiting.

Exerciseeasy

Clean up old cache files

The app caches downloaded images in cacheDir/images. Write a worker that deletes files older than 7 days, and schedule it once a day, only while the phone is charging and idle.

›

File.lastModified() gives the time in milliseconds. Constraints have requiresCharging and requiresDeviceIdle. Use unique periodic work so it isn’t scheduled twice.

SolutionTry it yourself first
class CacheCleanupWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
        val cutoff = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)
        File(applicationContext.cacheDir, "images").listFiles()
            ?.filter { it.lastModified() < cutoff }
            ?.forEach { it.delete() }
        Result.success()
    }
}

fun scheduleCacheCleanup(context: Context) {
    val request = PeriodicWorkRequestBuilder<CacheCleanupWorker>(1, TimeUnit.DAYS)
        .setConstraints(Constraints(requiresCharging = true, requiresDeviceIdle = true))
        .build()
    WorkManager.getInstance(context)
        .enqueueUniquePeriodicWork("cache-cleanup", ExistingPeriodicWorkPolicy.KEEP, request)
}

Charging and idle means the cleanup never competes with the user for battery or disk; if those conditions aren’t met on some day, it simply runs on a later one, which is fine for a cache.

  • Periodic: ≥15 min, drifts with Doze/buckets; always enqueueUniquePeriodicWork (KEEP, or UPDATE to change constraints); plain enqueue on every start multiplies schedules.
  • Chains propagate outputs (InputMerger for fan-in) and failures; observe Flow<WorkInfo> by unique name or tag; setProgress for UI.
  • Test with TestListenableWorkerBuilder and WorkManagerTestInitHelper + TestDriver.

Check this part

  1. What's the shortest interval for periodic work?

  2. In a chain compress → upload → confirm, what happens if upload fails?

  3. Why use enqueueUniquePeriodicWork instead of enqueue in Application.onCreate?

PeriodicWorkRequest runs at most every 15 minutes, at times the system chooses; enqueue it as unique work with KEEP or UPDATE so it isn't duplicated on every launch. Chain one-time workers with then() and pass outputs as inputs; observe progress and results as Flow.

Part Not started

Urgent and long-running work

Regular work runs “when the system thinks it’s a good time”, which can be minutes later. For things the user is waiting for, like sending a chat message they just typed, ask for expedited work: it starts right away if possible. For work that takes a long time and the user should see, like downloading a 2 GB map, Android requires a visible notification while it runs, so the user knows the app is working in the background.

Expedited work (setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)) runs immediately subject to an app-standby-based quota; below Android 12 it’s backed by a foreground service, so the worker must implement getForegroundInfo(). Long-running work uses setForeground(ForegroundInfo(...)) inside doWork() to promote itself to a foreground service with a notification. Since Android 14, foreground services must declare a type (dataSync, mediaPlayback, location, shortService…) in the manifest and at start, with matching permissions, and dataSync is time-limited on Android 15; background starts are restricted, which is why WorkManager is the recommended path.

class MapDownloadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        setForeground(foregroundInfo(progress = 0))                  // becomes a foreground service
        val regionId = inputData.getString("regionId") ?: return Result.failure()
        downloader.download(regionId) { percent ->
            setForeground(foregroundInfo(percent))                   // update the notification
            setProgress(workDataOf("percent" to percent))            // update the screen
        }
        return Result.success()
    }

    private fun foregroundInfo(progress: Int): ForegroundInfo {
        val notification = NotificationCompat.Builder(applicationContext, CHANNEL_DOWNLOADS)
            .setContentTitle("Downloading offline map")
            .setProgress(100, progress, false)
            .setSmallIcon(R.drawable.ic_download)
            .setOngoing(true)
            .addAction(R.drawable.ic_cancel, "Cancel",
                WorkManager.getInstance(applicationContext).createCancelPendingIntent(id))
            .build()
        return ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
    }
}
<!-- AndroidManifest.xml: required for foreground work on Android 14+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<service
    android:name="androidx.work.impl.foreground.SystemForegroundService"
    android:foregroundServiceType="dataSync"
    tools:node="merge" />
  • setForeground shows an ongoing notification and tells Android not to stop this work.
  • A Cancel button in the notification cancels the worker; users should always be able to stop long background work.
  • The service type (dataSync here) must be declared in the manifest on Android 14 and later, or the app crashes when the work starts.
  • Expedited work: immediate within a standby-bucket quota; out-of-quota policy; implement getForegroundInfo() for Android < 12.
  • Long-running workers: setForeground with a typed ForegroundInfo, progress notification and cancel action.
  • Android 14+: declared foreground service types and permissions, background-start restrictions; Android 15 time-limits dataSync.

Check this part

  1. What does Android require while long work runs in the foreground?

  2. What crashes a foreground worker on Android 14 that worked on Android 13?

Expedited work starts as soon as possible for short, important jobs, within a quota. Long, user-visible work runs as a foreground service or a worker with setForeground, showing a notification and declaring its type. Android 14+ requires a declared foreground service type and restricts starting them from the background.

Key takeaways

  • Use WorkManager for work that must finish after the user leaves: save data first, pass ids, enqueue.
  • Constraints wait for network, Wi-Fi or charging; retry brings backoff; failure is for errors retrying can’t fix.
  • Schedule periodic and one-off work as unique work, so it isn’t duplicated.
  • Long, visible work shows a notification with a Cancel action.
  • Guaranteed eventual execution, not timing; idempotent workers for at-least-once runs.
  • Unique periodic work with KEEP/UPDATE; chains propagate outputs and failures; observe WorkInfo flows.
  • Expedited for short urgent work; typed foreground workers for long user-visible work (Android 14+ rules).
  • Pick the tool by lifetime: viewModelScope, app scope, WorkManager, foreground service, exact alarms.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.