Zygote
Background workintermediate

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.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

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#

Loading diagram…
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:

  • enqueueUniqueWork with 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. Returning failure() 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#

SituationWrong choiceWhy it is wrongRight choice
Upload a photo the user just pickedForeground serviceNothing needs to be visible; the upload is shortCoroutine, with WorkManager for the retry
Sync the catalogue nightlyCoroutineDies when the app is killedWorkManager, periodic
Play a podcast in the backgroundWorkManagerThe user must be able to pause it and see it runningForeground service, mediaPlayback
Remind the user at 08:00Inexact alarmThe reminder is the productExact alarm, or a notification with a time
Refresh on connectivity changeManifest receiverBanned since API 26WorkManager with a network constraint
Track a run for 45 minutesWorkManagerToo frequent, and the user must see itForeground 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.

APIIf it fails, what happensWho notices
CoroutineThe work stops silently when the scope diesThe user, when the screen shows stale data
WorkManagerThe work runs late, possibly hours laterThe user, when the sync is old
Foreground serviceThe service is stopped by the systemThe user, because the notification disappears
AlarmManagerThe alarm fires late or is deferredThe user, if the time mattered
FCMThe message arrives late or is downgradedThe 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-idle and confirm an inexact alarm is deferred as expected.
  • Check the standby bucket with am get-standby-bucket and 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#

2008–2014Service-firstthe app owns its own scheduling
2014–2018Defer to the systemJobScheduler, Doze, inexact alarms
2018–2021The library layerWorkManager makes the system APIs usable
2021–nowTyped and time-limitedFGS types, launch rules, dataSync deadline
Background and power changes12 releases
APIVersionWhat changed
11.0
  • The original android.* SDK: Activity, Service, BroadcastReceiver, ContentProvider
174.2 Jelly Bean
  • Daydream service
194.4 KitKat
  • AlarmManager becomes inexact by default for targeting apps
215.0 Lollipop
  • JobScheduler
  • Implicit service intents restricted for targeting apps
268.0 Oreo
  • Background service limits
289 Pie
  • Foreground service permission
2910
  • Background activity starts restricted
3011
  • Background location requires a second request
3112
  • Exact alarms need SCHEDULE_EXACT_ALARM
  • Foreground service launch restrictions from the background
3414 Upside Down Cake
  • Foreground service types required when targeting 34
  • User-initiated data transfer jobs
3515 Vanilla Ice Cream
  • Deprecated APIs: WindowInsetsController hiding, elevate the importance of foreground services
3616 Baklava
  • JobScheduler quota changes

Under the hood#

Curiosity corner#

  • setAndAllowWhileIdle is throttled in Doze — only a handful of alarms per hour get through. setExactAndAllowWhileIdle has 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_STICKY does 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#

See your app's jobs

adb shell dumpsys jobscheduler | grep -A4 com.example.app

Example output
JOB #u0a123/1001: com.example.app/androidx.work.impl.background.systemjob.SystemJobService
Check exact-alarm permission

adb shell appops get com.example.app SCHEDULE_EXACT_ALARM

Example output
android.permission.SCHEDULE_EXACT_ALARM: granted=false
Inspect a foreground service

adb 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

1 / 12

Put these background restrictions in the order they were introduced.

Drag, or use the arrows to reorder.

  1. 1Foreground service types become mandatory (API 34)
  2. 2Background services are stopped after the app leaves the foreground (API 26)
  3. 3Alarms become inexact by default (API 19)
  4. 4Background activity starts are blocked (API 29)
  5. 5Doze defers network, jobs and alarms (API 23)
0 answered · 0 correct

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.

  1. docsWorkmanagerdeveloper.android.comaccessed 2026-10-27↩
  2. docsForeground services overview | Background workdeveloper.android.comaccessed 2026-10-27↩
  3. docsBehavior changes: Apps targeting Android 12developer.android.comaccessed 2026-10-27↩
  4. docsBackground tasks overview | Background workdeveloper.android.comaccessed 2026-10-27↩
  5. docsSchedule alarmsdeveloper.android.comaccessed 2026-10-27↩
  6. docsOptimize for Doze and App Standbydeveloper.android.comaccessed 2026-10-27↩
  7. docsStandby bucketsdeveloper.android.comaccessed 2026-10-27↩
  8. docsBehavior changes: Apps targeting Android 14developer.android.comaccessed 2026-10-27↩