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:
| Behavior | Why it matters |
|---|---|
| Respects OS battery constraints | WorkManager 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 restarts | A scheduled work request survives the app being closed and even the device being rebooted — WorkManager re-schedules it automatically |
| Guaranteed eventual execution | Even 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
}