Background work, notifications and WorkManager
WorkManager with constraints and backoff, unique and chained work, foreground services, and notification channels and permissions.
WorkManager and constraints
class SyncWorker(
appContext: Context,
params: WorkerParameters,
private val repo: BookRepository,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val since = inputData.getLong(KEY_SINCE, 0L)
return try {
// idempotent: re-running must be safe
repo.syncSince(since)
Result.success()
} catch (e: IOException) {
if (runAttemptCount < 5) Result.retry() else Result.failure()
}
}
companion object { const val KEY_SINCE = "since" }
}
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val repo: BookRepository,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = runCatching { repo.sync() }
.fold({ Result.success() }, { if (runAttemptCount < 5) Result.retry() else Result.failure() })
}
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build()
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag("sync")
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
"sync",
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build(),
)- WorkManager persists the request, so work survives process death and a device reboot. It is the right tool for anything that must eventually happen.
- Periodic work has a 15-minute minimum. If you need something faster, that is a foreground service or a push message, not a shorter interval.
- The unique work name prevents duplicates but does nothing about concurrency.
ExistingPeriodicWorkPolicy.UPDATEreplaces the definition, whileKEEPleaves an existing one alone. - Every worker must be idempotent: the system can retry after a crash, and the retry happens without warning.
⚠️
Do not use WorkManager for work the user is waiting for. It is optimised for deferrable work scheduled by the system, and a user-facing operation can be delayed by Doze. Run it in the foreground, in the ViewModel scope, or as a foreground service with a notification.
Chaining and observing
val download = OneTimeWorkRequestBuilder<DownloadWorker>().build()
val process = OneTimeWorkRequestBuilder<ProcessWorker>().build()
val upload = OneTimeWorkRequestBuilder<UploadWorker>().build()
WorkManager.getInstance(context)
.beginWith(download)
.then(process)
.then(upload)
.enqueue()
// observe progress from the UI
WorkManager.getInstance(context)
.getWorkInfosForUniqueWorkFlow("sync")
.map { infos -> infos.firstOrNull()?.state ?: WorkInfo.State.ENQUEUED }
.distinctUntilChanged()
// pass data between workers
val output = workDataOf(KEY_FILE to path)
// setOutputData(output) in the worker; the next worker reads inputData
// cancellation
WorkManager.getInstance(context).cancelUniqueWork("sync")| State | Meaning | Action |
|---|---|---|
ENQUEUED | Waiting for constraints | Show a pending indicator |
RUNNING | Executing now | Show progress if available |
SUCCEEDED | Finished | Clear the pending indicator |
FAILED | Permanent failure | Surface an error and offer a retry |
BLOCKED | Waiting on an earlier worker in the chain | Nothing - the chain drives it |
CANCELLED | Cancelled by the app or the user | Stop showing progress |
Foreground services and notifications
class UploadService : LifecycleService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Uploading")
.setSmallIcon(R.drawable.ic_upload)
.setOngoing(true)
.setProgress(0, 0, true)
.build()
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, FOREGROUND_TYPE)
lifecycleScope.launch {
try {
uploader.run()
} finally {
stopSelf()
}
}
return START_NOT_STICKY
}
}
// create the channel once, at application start
val channel = NotificationChannel(
CHANNEL_ID, "Uploads", NotificationManager.IMPORTANCE_LOW,
).apply { description = "Background upload progress" }
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
// Android 13+ requires the runtime permission before posting anything
// <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
val launcher = rememberLauncherForActivityResult(RequestPermission()) { granted ->
if (!granted) showRationale()
}
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)- Android 14 requires a declared foreground service type in the manifest and a matching permission. Starting one without it throws, so declare the type you actually need.
- A foreground service must post a notification and must start it within a few seconds of
startForegroundServiceor the system kills the process. - Notifications require the runtime permission on Android 13+. Ask in context, when the user starts the action that needs it, not on first launch.
- Channels are created once and cannot have their importance changed by the app afterwards - the user owns that. Get the importance right in the first release.
- Use
IMPORTANCE_LOWfor progress andIMPORTANCE_HIGHonly for something genuinely time-critical, or users will turn the channel off.
FAQ
WorkManager or a coroutine in the ViewModel?
A coroutine if the user is waiting and the screen is on. WorkManager when the work must complete even if the user leaves, or must wait for a constraint such as a network or charging. Foreground services when the user must be aware of ongoing work.
Why did my periodic work not run on time?
The system batches deferrable work around Doze and app standby, so the interval is a minimum, not a schedule. Add constraints, keep the worker short, and never rely on exact timing.
Related
Permissions, storage and publishing Testing Android apps
Last refreshed 2026-09-18.