Choosing the right API
Coroutine, WorkManager, foreground service, AlarmManager, JobScheduler: five answers to five different questions. The first question is not which API is best — it is how long the work must survive.
Most background-work bugs are not bugs in the chosen API. They are bugs in the choice: a coroutine used for work that must survive the process, or a foreground service used for work nobody needed to see.
TL;DR#
- Survive the screen? If not, a lifecycle-aware coroutine is the right answer and everything else is over-engineering.
- The user must see it? A foreground service, with the type and permission that match.
- It can wait for conditions? WorkManager: persistent, constraint-aware, chainable.
- A specific wall-clock time? AlarmManager, exact only if you have the permission and a legitimate alarm-clock use case.
- Something else's problem? Push the trigger to a server, and use FCM to wake the app only when there is something to do.
Mental model#
Think of five ways to get a parcel delivered.
A courier you hand it to right now is a coroutine: fast, direct, and if you leave the building before it arrives, nobody is there to receive it.
A scheduled pickup is WorkManager: the parcel is logged, someone will collect it when the route allows, and it survives you going home.
A same-day tracked delivery with a van you can see is a foreground service: the recipient can watch it move, and the company has promised it is happening now.
An alarm clock is AlarmManager: it must happen at 07:00, not "when convenient", and it costs you more to book.
A notification from the sender is push: you do not poll the depot; the depot tells you when the parcel exists.
Where the analogy breaks#
A parcel has one recipient and one delivery. Background work can be chained: download, then transform, then upload, with retries and constraints between steps. WorkManager is the only one of the five that models a chain, which is why it is the default for anything with more than one step.
The big picture#
Text description of this diagram
An interactive decision tree. The first question asks whether the work must run while the app is not visible. If no, the answer is a lifecycle-aware coroutine, which dies with the process. If yes, the second question asks whether the user must see it running. If yes, the answer is a foreground service, which needs a type and permission on API 34 and cannot be started from the background on API 31 without an exemption. If no, the third question asks whether the trigger is a condition or a chain. A condition or chain gives WorkManager, which persists and supports constraints and chaining. An exact wall-clock time leads to a fourth question about drift: approximate drift is fine gives an inexact AlarmManager alarm, and drift is unacceptable gives an exact alarm that requires SCHEDULE_EXACT_ALARM and is restricted by Play policy to alarm-clock and calendar apps.
Click through it. The tree is deliberately ordered so that the cheapest correct answer comes first: most work people reach for WorkManager to do is really a coroutine, and most work people reach for a foreground service to do is really WorkManager.
Deep dive#
Coroutines: the default#
A coroutine tied to a lifecycle scope is the right answer whenever the work's value ends when the screen does. Loading a list, validating a form, uploading a photo the user just took — if the user leaves, the work can die.
class ProfileViewModel(
private val repository: ProfileRepository,
) : ViewModel() {
private val _state = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
val state: StateFlow<ProfileUiState> = _state.asStateFlow()
init {
viewModelScope.launch {
_state.value = try {
ProfileUiState.Ready(repository.load())
} catch (e: IOException) {
ProfileUiState.Error(e.message)
}
}
}
}viewModelScope cancels in onCleared(). That is the whole safety story: no leaked callback into a
destroyed screen, no need to check isFinishing.
WorkManager: persistent and deferrable#
WorkManager is for work that must survive the process and can tolerate the system choosing when. It writes each request to a database before scheduling it, so a reboot, a crash or a force-stop does not lose it.1
class UploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val photoId = inputData.getString(KEY_PHOTO_ID) ?: return Result.failure()
return try {
repository.upload(photoId)
Result.success()
} catch (e: IOException) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.setInputData(workDataOf(KEY_PHOTO_ID to photoId))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"upload-$photoId",
ExistingWorkPolicy.KEEP,
request,
)Three details in that snippet are load-bearing:
enqueueUniqueWorkwith a stable name prevents the same upload being queued twice. Without it, a user tapping "retry" five times creates five uploads.Result.retry()respects the backoff policy. Returningfailure()gives up; retrying forever is how an app gets classified as a battery offender.- Constraints are conditions, not triggers. Network-connected is checked when the system is ready to run the work; it is not a promise to run the instant connectivity returns.
Foreground services: when the user must know#
A foreground service is the only way to keep running indefinitely while the app is not visible, and the price is a notification the user cannot dismiss while the service runs.2
class SyncService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_sync)
.setContentTitle("Syncing")
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
return START_NOT_STICKY
}
}The FOREGROUND_SERVICE_TYPE_DATA_SYNC argument and the manifest declaration are both required on
API 34+, along with the FOREGROUND_SERVICE_DATA_SYNC permission. And on API 35, dataSync has a
time budget — the system stops it, which is precisely the push toward WorkManager for long syncs.
The foreground services page has the full set of rules.
AlarmManager: a wall-clock contract#
Alarms are for things that must happen at a time, not at a condition. Two shapes:
// Inexact: the system may batch this with other alarms.
val alarmManager = context.getSystemService(AlarmManager::class.java)
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent,
)// Exact: requires SCHEDULE_EXACT_ALARM, and the user can revoke it.
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent,
)
}canScheduleExactAlarms() is the check that matters. On Android 12+, an app without the permission
that calls an exact alarm API gets a SecurityException on some paths and silent inexact behaviour
on others — neither of which is a good user experience.3
JobScheduler: the layer beneath#
WorkManager uses JobScheduler on modern devices. You can use it directly, and sometimes should — for example when you need a job that is not a unit of app work but a system-level constraint, or when you are writing code that cannot depend on AndroidX.
The trade: JobScheduler persists across reboots for jobs scheduled with setPersisted(true) and the
RECEIVE_BOOT_COMPLETED permission, but it has no database of results, no chaining and no unique
work. Those are WorkManager features built on top.
The comparisons people actually get wrong#
| Situation | Wrong choice | Why it is wrong | Right choice |
|---|---|---|---|
| Upload a photo the user just picked | Foreground service | Nothing needs to be visible; the upload is short | Coroutine, with WorkManager for the retry |
| Sync the catalogue nightly | Coroutine | Dies when the app is killed | WorkManager, periodic |
| Play a podcast in the background | WorkManager | The user must be able to pause it and see it running | Foreground service, mediaPlayback |
| Remind the user at 08:00 | Inexact alarm | The reminder is the product | Exact alarm, or a notification with a time |
| Refresh on connectivity change | Manifest receiver | Banned since API 26 | WorkManager with a network constraint |
| Track a run for 45 minutes | WorkManager | Too frequent, and the user must see it | Foreground service, location, started while visible |
The cost model behind the choice#
Each API has a different failure mode, and choosing well means knowing which failure you can tolerate.
| API | If it fails, what happens | Who notices |
|---|---|---|
| Coroutine | The work stops silently when the scope dies | The user, when the screen shows stale data |
| WorkManager | The work runs late, possibly hours later | The user, when the sync is old |
| Foreground service | The service is stopped by the system | The user, because the notification disappears |
| AlarmManager | The alarm fires late or is deferred | The user, if the time mattered |
| FCM | The message arrives late or is downgraded | The user, if it was urgent |
That table is the reason "which API is best" is the wrong question. The right question is "how much lateness can this feature tolerate, and who needs to know if it does not run?" The answer picks the API, and the API's cost follows.
Testing the choice#
A background-work decision is testable before it ships, and the tests are cheap.
- Force-stop the app and confirm the work still happens. If it does not, you chose a coroutine where you needed persistence.
- Turn on airplane mode and confirm a constrained job waits rather than failing.
- Put the device in Doze with
dumpsys deviceidle force-idleand confirm an inexact alarm is deferred as expected. - Check the standby bucket with
am get-standby-bucketand confirm your assumption about how often the app may run.
Each test takes a minute and answers a question that would otherwise be answered by a bug report from a user whose device behaves differently from the emulator.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 17 | 4.2 Jelly Bean |
|
| 19 | 4.4 KitKat |
|
| 21 | 5.0 Lollipop |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 34 | 14 Upside Down Cake |
|
| 35 | 15 Vanilla Ice Cream |
|
| 36 | 16 Baklava |
|
Under the hood#
- JobScheduler.javathe platform API WorkManager sits on
- AlarmManager.javaexactness, allow-while-idle and the permission checks
- Service.javastartForeground and the foreground service type argument
- ActivityManager.javaprocess importance, which is what background restrictions actually gate on
Curiosity corner#
setAndAllowWhileIdleis throttled in Doze — only a handful of alarms per hour get through.setExactAndAllowWhileIdlehas its own throttle. Neither is a general-purpose timer.- A force-stopped app loses its WorkManager jobs until the user opens it again. The database survives; the scheduling does not.
START_STICKYdoes not mean "restart immediately". It means the system may recreate the service later, with a null intent, if it has resources and the app is not restricted.
Try it#
adb shell dumpsys jobscheduler | grep -A4 com.example.app
Example output
JOB #u0a123/1001: com.example.app/androidx.work.impl.background.systemjob.SystemJobServiceadb shell appops get com.example.app SCHEDULE_EXACT_ALARM
Example output
android.permission.SCHEDULE_EXACT_ALARM: granted=falseadb shell dumpsys activity services com.example.app
Example output
ServiceRecord{... com.example.app/.SyncService}
isForeground=true foregroundId=1001 foregroundNoti=NotificationRecord{...}Build it#
Take a screens-and-requests app and replace one "fire and forget" coroutine with a WorkManager request. Then force-stop the app from Settings, reopen it, and observe that the work is still there. That difference — surviving a force-stop — is the entire reason WorkManager exists.
Check yourself#
Check yourself
Put these background restrictions in the order they were introduced.
Drag, or use the arrows to reorder.
- 1Foreground service types become mandatory (API 34)
- 2Background services are stopped after the app leaves the foreground (API 26)
- 3Alarms become inexact by default (API 19)
- 4Background activity starts are blocked (API 29)
- 5Doze defers network, jobs and alarms (API 23)
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsWorkmanagerdeveloper.android.comaccessed 2026-10-27↩
- docsForeground services overview | Background workdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 12developer.android.comaccessed 2026-10-27↩
- docsBackground tasks overview | Background workdeveloper.android.comaccessed 2026-10-27↩
- docsSchedule alarmsdeveloper.android.comaccessed 2026-10-27↩
- docsOptimize for Doze and App Standbydeveloper.android.comaccessed 2026-10-27↩
- docsStandby bucketsdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 14developer.android.comaccessed 2026-10-27↩