← All problems

Mobile Core Concepts

Background Work and Scheduling

Why work you start after the user leaves the screen has to be scheduled, not threaded, and the OS constraints that decide which scheduler a given job earns.

Free~20 min

Almost every interesting Android problem - photo upload, crash report flush, message reconnect, periodic feed sync - has a background-work shape: something must run when the app isn't on screen, possibly after a reboot, under battery and network constraints the OS won't negotiate on. Android exposes four schedulers (WorkManager, JobScheduler, AlarmManager, Foreground Service) and one OS-wide regime (Doze + App Standby) every scheduler must respect. This page covers how each is designed, the constraints they cost, and the decision flow for picking between them. The Doze / App Standby state machine itself lives in Platform Knowledge; this page builds on it.

The scheduler landscape

Four schedulers, designed for four different shapes of work:

SchedulerWhat it's forCostDoze-aware
WorkManagerDeferrable, guaranteed work with constraintsNone - the defaultYes (auto)
Foreground ServiceUser-visible ongoing work that must run nowVisible notification, typed permissionExempt
AlarmManager (exact)Wall-clock alarms with deadline semanticsUser-revocable permission on API 31+Bypasses Doze for the alarm fire only
JobSchedulerPre-WorkManager constraint-based schedulingNone, but obsoleted by WorkManagerYes

WorkManager is the right answer for nearly all background work on modern Android. It sits on top of JobScheduler (API 23+); you call JobScheduler directly only for JobIntentService-era integration. AlarmManager survives for one job: firing at a specific wall-clock time when missing the deadline is a user-visible failure. Foreground Service is for work that's part of an in-progress user task - upload they're watching, music they're listening to, navigation they're following.

The interview anti-pattern is reaching for AlarmManager because you want "every 15 minutes." That's WorkManager periodic work. Exact alarms cost a user-revocable permission, burn battery, and are audited by Play - reserve for true deadlines.

Sources: Background work overview · WorkManager

WorkManager

WorkManager guarantees work runs eventually - across process death and reboot - subject to the constraints you declare. A WorkRequest describes the work; the system picks when. The unit of work is a Worker or CoroutineWorker:

class UploadWorker(ctx: Context, params: WorkerParameters) :
    CoroutineWorker(ctx, params) {
 
    override suspend fun doWork(): Result {
        val photoId = inputData.getString("photoId") ?: return Result.failure()
        return runCatching { uploader.upload(photoId) }
            .fold(
                onSuccess = { Result.success() },
                onFailure = { if (runAttemptCount < 5) Result.retry() else Result.failure() },
            )
    }
}

CoroutineWorker is the default - it integrates with structured concurrency and Dispatchers.IO. Plain Worker is synchronous on a background thread pool; use it only if you genuinely don't want suspension. Either way, doWork() returns Result.success(), Result.retry(), or Result.failure().

Keep inputData small: Data serializes through a Bundle with a ~10 KB practical limit, primitives and arrays only. Pass an ID and let the worker re-read the full record from Room - never the record itself.

Constraints

A WorkRequest carries Constraints the OS evaluates before running it - work waits until all are satisfied:

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)  // Wi-Fi or unmetered ethernet
    .setRequiresBatteryNotLow(true)
    .setRequiresCharging(false)
    .setRequiresStorageNotLow(true)
    .build()
 
val req = OneTimeWorkRequestBuilder<UploadWorker>()
    .setConstraints(constraints)
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
    .setInputData(workDataOf("photoId" to id))
    .build()
ConstraintEffect
NetworkType.CONNECTEDAny network
NetworkType.UNMETEREDWi-Fi only (no cellular billing)
NetworkType.METEREDCellular only (rare)
setRequiresBatteryNotLow(true)Defers when battery < 15%
setRequiresCharging(true)Only while plugged in
setRequiresDeviceIdle(true)Only while idle
setRequiresStorageNotLow(true)Defers when free storage low

Constraints compose: UNMETERED + CHARGING is the right shape for nightly sync of large photos.

One-time vs periodic

OneTimeWorkRequest runs once. PeriodicWorkRequest repeats on an interval - minimum 15 minutes, with drift because the OS batches jobs:

val nightlySync = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
    .setConstraints(constraints)
    .setInitialDelay(2, TimeUnit.HOURS)
    .build()
 
WorkManager.getInstance(ctx)
    .enqueueUniquePeriodicWork("nightly-sync", ExistingPeriodicWorkPolicy.KEEP, nightlySync)

You cannot get sub-15-minute periodicity from WorkManager. Finer cadence belongs in a foreground service or a LifecycleObserver-tied coroutine while the app is open.

Chaining and WorkContinuation

WorkManager builds DAGs of dependent work. then chains sequentially; combine joins parents:

val compress = OneTimeWorkRequestBuilder<CompressWorker>().build()
val upload   = OneTimeWorkRequestBuilder<UploadWorker>().build()
val notify   = OneTimeWorkRequestBuilder<NotifyWorker>().build()
 
WorkManager.getInstance(ctx)
    .beginWith(compress)
    .then(upload)
    .then(notify)
    .enqueue()

outputData of one worker is auto-passed as inputData to the next. A permanent failure (Result.failure()) cancels downstream work.

Unique work and ExistingWorkPolicy

Most interesting work is unique: only one outstanding "sync feed" task at a time. Unique work has a name and a policy for what to do if a request with the same name is already queued:

PolicyBehaviorUse when
KEEPIgnore the new request if one is queued or runningIdempotent sync; dedup is enough
REPLACECancel existing, enqueue newInputs changed; old run is stale
APPENDQueue after existing; cascading failureStrict ordering
APPEND_OR_REPLACEQueue after existing; independent failureStrict ordering, fault-tolerant

APPEND_OR_REPLACE is the right default for "user took an action that flushes after the in-flight one" - a stuck retry won't permanently block the queue.

Backoff and retries

Result.retry() re-enqueues with the configured backoff. Default is BackoffPolicy.EXPONENTIAL, 10-second initial delay, capped at 5 hours. WorkManager retries forever until you return Result.failure() - your worker caps attempts via runAttemptCount.

override suspend fun doWork(): Result {
    if (runAttemptCount >= MAX_ATTEMPTS) return Result.failure()
    return try { Result.success(...) } catch (_: IOException) { Result.retry() }
}

Returning Result.success() after a partial completion is a common bug - the worker is then forgotten. If you've uploaded 3 of 10 chunks and want to resume, return Result.retry(), persist the offset, and pick up next attempt.

Expedited work

On Android 12+, setExpedited() asks the OS to run the work ASAP, ignoring most constraints and skipping Doze deferrals. The cost: it counts against the app's foreground service quota - the same budget that limits background-started FGSes. Quota replenishes slowly; abuse it and your expedited work silently falls back to deferred:

val req = OneTimeWorkRequestBuilder<SendMessageWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST is the right fallback - the work still runs, just deferred. DROP_WORK_REQUEST silently drops it, which is almost never what you want.

Use expedited for user-perceived urgency: send-this-message-now, post-this-comment-now. Don't use it for background sync - you'll burn quota and end up on the slow path anyway.

Foreground services

A foreground service (FGS) is a Service with a visible notification the OS treats as user-visible. While the notification is displayed, the service is exempt from Doze and background limits, can hold wakelocks, and survives swipe-away in most cases. The cost: a visible notification, and on Android 14+ a typed permission per FGS type.

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    val notif = NotificationCompat.Builder(this, CHANNEL_ID)
        .setSmallIcon(R.drawable.ic_upload)
        .setContentTitle("Uploading 3 photos")
        .setOngoing(true)
        .build()
    ServiceCompat.startForeground(
        this, NOTIF_ID, notif,
        ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
    )
    return START_STICKY
}

On Android 14+, every FGS must declare a foregroundServiceType in the manifest and at startForeground(), matching a granted permission:

TypeManifest permissionUse for
dataSyncFOREGROUND_SERVICE_DATA_SYNCUploads, downloads, sync
mediaPlaybackFOREGROUND_SERVICE_MEDIA_PLAYBACKAudio / video
locationFOREGROUND_SERVICE_LOCATION + runtime locationGPS in background
phoneCallFOREGROUND_SERVICE_PHONE_CALLVoIP
mediaProjectionFOREGROUND_SERVICE_MEDIA_PROJECTIONScreen recording
microphoneFOREGROUND_SERVICE_MICROPHONERecording audio
cameraFOREGROUND_SERVICE_CAMERABackground camera
healthFOREGROUND_SERVICE_HEALTHFitness tracking
remoteMessagingFOREGROUND_SERVICE_REMOTE_MESSAGINGBluetooth / wearable
shortService(none)< 3 min, no special permission
specialUseFOREGROUND_SERVICE_SPECIAL_USECatch-all; Play review
systemExemptedFOREGROUND_SERVICE_SYSTEM_EXEMPTEDSystem apps only

Mismatch the type and you get MissingForegroundServiceTypeException at start. WorkManager exposes the same pattern via setForeground() - the worker promotes itself to an FGS for the run, the idiomatic way to keep a long upload alive without writing a Service.

JobScheduler and AlarmManager

You rarely call these directly. WorkManager is implemented on top of JobScheduler; reaching past it usually means lifting the abstraction (custom WorkerFactory), not dropping down.

AlarmManager is the exception. It exists for one purpose: fire a PendingIntent at a specific wall-clock time, even if the device is in Doze:

val pi = PendingIntent.getBroadcast(
    ctx, 0, Intent(ctx, ReminderReceiver::class.java),
    PendingIntent.FLAG_IMMUTABLE,
)
alarmManager.setExactAndAllowWhileIdle(
    AlarmManager.RTC_WAKEUP,
    triggerAtMillis,
    pi,
)

setExactAndAllowWhileIdle is the Doze-bypassing variant - fires even in deep Doze, subject to a one-per-app-per-9-minutes throttle. set is the inexact variant the OS may batch.

On API 31+, exact alarms require permission. USE_EXACT_ALARM is auto-granted for alarm / calendar / messaging apps (Play reviews the usage). SCHEDULE_EXACT_ALARM is user-revocable in Settings - your code must handle the revoked state:

if (Build.VERSION.SDK_INT >= 31 && !alarmManager.canScheduleExactAlarms()) {
    // permission revoked - fall back to inexact, or prompt
    startActivity(Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM))
    return
}
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)

The Doze allowlist covers only the fire - whatever the alarm starts still runs under Doze. If real work has to follow, the alarm should enqueue a one-shot WorkRequest.

Doze and App Standby

Both regimes are covered in Platform Knowledge. The summary, applied to schedulers:

  • Doze (device idle): WorkManager defers all jobs except expedited and FGS-promoted workers until a maintenance window. AlarmManager setExactAndAllowWhileIdle bypasses Doze for the fire only. An active FGS is exempt for its duration.
  • App Standby buckets (per-app, by recent usage): determines how often jobs run. Active / Working Set run near-normally; Frequent / Rare / Restricted see exponentially fewer runs. Periodic work in Rare may run once every 24 hours regardless of your 15-minute interval.

WorkManager is the right citizen of these regimes - it schedules within them and exposes the knobs (setExpedited, FGS promotion) for exceptions. Reaching past it to AlarmManager because "WorkManager is unreliable" usually means the work is on the wrong scheduler.

Picking the right scheduler

The decision flow, top to bottom:

  1. User looking at it now and would notice if it stopped? Foreground Service. (Upload-with-progress, music, navigation.)
  2. Must run at a specific wall-clock time; missing it is user-visible? AlarmManager exact + permission. (Alarm clock, calendar reminder.)
  3. Must run soon but the app isn't in the foreground - user-perceived urgency? WorkManager + setExpedited. (Send queued message after reconnect.)
  4. Should run when conditions are right, eventually? WorkManager with constraints. (Photo upload on Wi-Fi, nightly sync, crash report flush.)
  5. Fire-and-forget while the app is open? A coroutine on the right scope (viewModelScope, a ProcessLifecycleOwner-tied scope). Not a scheduler problem - see Concurrency.

The mistake candidates make is reaching for AlarmManager because of "every 15 minutes" or "in the background." Neither implies a deadline. Both are WorkManager.

See also

  • Concurrency - CoroutineWorker is the bridge between WorkManager and structured concurrency; Result.retry() + cancellation cooperation is how a worker survives the OS killing it mid-run.
  • Platform Knowledge - the Doze state machine and App Standby buckets that all schedulers respect live there.
  • Push and Notifications - FCM data messages frequently trigger a one-off setExpedited WorkRequest; the receiver is supposed to be brief.
  • State Management - inputData / outputData limits, and where worker state lives across retries (Room, DataStore).

Used in

  • Photo Gallery App - UploadWorker is a CoroutineWorker with NetworkType.UNMETERED, promoted to an FGS via setForeground(ForegroundInfo(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC)) during the upload.
  • Crash Reporter SDK - next-launch uploader is a unique OneTimeWorkRequest enqueued from Application.onCreate with ExistingWorkPolicy.KEEP; reports flush even on a process that dies again before first frame.
  • Messenger App - reconnect strategy: WorkManager schedules a backoff retry chain on WebSocket failure outside the foreground; outbound sends are expedited work that promotes to an FGS if delayed.
  • Newsfeed App - periodic feed prefetch is a 6-hour PeriodicWorkRequest with UNMETERED + BATTERY_NOT_LOW; a backgrounded user in Rare may see runs collapse to daily.

Done reading? Mark it so it sticks in your dashboard.