WorkManager

Say a TrailLog user logs three hikes while out of signal range, then closes the app before it gets a chance to sync them to the server. Or say you want a nightly job that clears out old cached thumbnails. Both are background work that has to actually happen — even if the app is closed, even if the device reboots in the meantime. A coroutine launched from an application-level scope disappears the moment the process dies. WorkManager is the Jetpack library built specifically to guarantee work like this really runs.

Why WorkManager, Not Just a Background Coroutine

It's tempting to think "I'll just launch a coroutine and let it run in the background." That works only as long as the process is alive. WorkManager is different in two ways that matter a lot for real apps:

BehaviorWhy it matters
Respects OS battery constraintsWorkManager cooperates with Doze mode and battery optimization instead of fighting them, so the system doesn't kill your work or drain the user's battery
Persists across restartsA scheduled work request survives the app being closed and even the device being rebooted — WorkManager re-schedules it automatically
Guaranteed eventual executionEven if conditions aren't right immediately (no WiFi yet, not charging yet), WorkManager keeps the request queued until they are

Defining the Work: CoroutineWorker

The actual unit of work is a class extending CoroutineWorker, which plugs straight into coroutines instead of forcing you to write blocking code. The one method you override, doWork(), is itself a suspend function:

TrailSyncWorker: uploading any trails saved while offline

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

    override suspend fun doWork(): Result {
        return try {
            val repository = TrailRepository.getInstance(applicationContext)
            repository.syncUnsyncedTrails()
            Result.success()
        } catch (e: IOException) {
            Result.retry() // transient network failure — try again later
        } catch (e: Exception) {
            Result.failure() // something unrecoverable — don't keep retrying
        }
    }
}

doWork() returns one of three outcomes: Result.success(), Result.retry() to try again later using WorkManager's backoff policy, or Result.failure() to give up. Because doWork() is a suspend function, it can freely call other suspend functions — network calls, Room queries, anything — the same way you would inside a ViewModel.

One-Time vs. Periodic Work Requests

A Worker class describes what to do; a work request describes when and how often. OneTimeWorkRequestBuilder runs something once — a good fit for the trail sync above, triggered right after the user saves an offline trail. PeriodicWorkRequestBuilder repeats on an interval — useful for a daily nudge reminding the user to log a hike:

A daily "log a hike" reminder

val dailyReminderRequest = PeriodicWorkRequestBuilder<HikeReminderWorker>(
    1, TimeUnit.DAYS
).build()

WorkManager.getInstance(context)
    .enqueueUniquePeriodicWork(
        "daily_hike_reminder",
        ExistingPeriodicWorkPolicy.KEEP,
        dailyReminderRequest
    )

The shortest allowed interval for periodic work is 15 minutes — WorkManager deliberately won't run more frequently, since this is meant for maintenance-style work, not tight polling. enqueueUniquePeriodicWork with ExistingPeriodicWorkPolicy.KEEP is worth using here too: it stops the app from accidentally scheduling a second daily reminder every time this code runs again.

Constraints: Only Run When Conditions Are Right

Uploading trail data is exactly the kind of job you don't want firing on a weak mobile connection or draining an already-low battery. Constraints.Builder() lets you describe the conditions WorkManager should wait for before it even attempts the work:

Only sync on WiFi while charging

val syncConstraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .setRequiresCharging(true)
    .build()

val syncRequest = OneTimeWorkRequestBuilder<TrailSyncWorker>()
    .setConstraints(syncConstraints)
    .build()

NetworkType.UNMETERED means "WiFi, not mobile data" — WorkManager won't even start the worker until both constraints are satisfied, and it automatically pauses and retries the work if a condition stops holding partway through.

Enqueueing the Work

Once you have a request built, handing it to WorkManager is one call:

WorkManager.getInstance(context).enqueue(syncRequest)

From this point on, WorkManager owns the request — it decides exactly when the constraints are met and runs doWork() then, persisting the request even if the app process is killed or the device restarts before conditions are satisfied.

Observing Work Status for UI Feedback

Sometimes the UI needs to reflect what's happening — showing a small "syncing…" indicator while TrailSyncWorker runs. WorkManager exposes each request's status as LiveData<WorkInfo>, so the ViewModel can observe it directly:

val workInfo: LiveData<WorkInfo> =
    WorkManager.getInstance(context).getWorkInfoByIdLiveData(syncRequest.id)

workInfo.observe(viewLifecycleOwner) { info ->
    val isSyncing = info?.state == WorkInfo.State.RUNNING
    // toggle a "syncing..." view based on isSyncing
}
Note: This is correct, working WorkManager code, but this website's own project doesn't run a live Android Studio build, so none of these workers actually execute on this page.
Up next: WorkManager handles work that must run reliably on its own schedule. For work that runs while your app is actively open — loading a few things at once, handling failures gracefully — Structured Concurrency in Practice ties the coroutine concepts from earlier in the track into realistic, combined patterns.