⚙️ Core Concept·~55 min read·Intermediate → Advanced
WorkManager, JobScheduler & AlarmManager
How Android schedules work that must survive process death — WorkManager’s full lifecycle, constraints, chaining, expedited work, Hilt injection, testing, JobScheduler’s trigger model, AlarmManager’s exact timing, Doze mode survival, App Standby Buckets, and the decision tree for picking the right tool every time.
⚙️ WorkManager
📋 JobScheduler
⏰ AlarmManager
💤 Doze Mode
🔗 Chaining
The big picture
Android is aggressively hostile to background work. Since Android 6.0 (Doze), 8.0 (Background Execution Limits), and 12.0 (exact alarm restrictions), the OS has progressively throttled, deferred, and outright killed background processes to protect battery life. If you want work to run outside your app’s foreground lifetime, you need to cooperate with the OS scheduler — not fight it.
Three APIs sit at different levels of this problem. They are not interchangeable. Each one solves a fundamentally different scheduling problem:
📋 Background scheduling APIs — pick by problem, not by familiarity
✅ Default choice
When in doubt, use WorkManager. It handles backward compatibility, picks the best underlying scheduler for the API level, persists work across process death and reboots, and gives you constraints, chaining, and observable state for free. Only reach for JobScheduler or AlarmManager directly when you have a specific need WorkManager cannot serve.
WorkManager architecture
WorkManager is built on a simple insight: the OS will kill your process, but it won’t wipe your disk. WorkManager stores every pending work request in a Room database on disk before any scheduling happens. On device reboot, a BOOT_COMPLETED receiver wakes WorkManager, which reads the database and re-schedules everything that hasn’t finished. Your app doesn’t need to handle reboots at all — it’s built into the library.
Under the hood, WorkManager delegates to the best available executor for the current API level. On API 23+ that’s JobScheduler. On API 14–22 it falls back to a combination of AlarmManager and BroadcastReceivers. You never write version checks for this — WorkManager’s WorkManagerScheduler does it transparently.
📋 WorkManager internal architecture — from enqueue to execution
Worker types
WorkManager offers three base classes. Pick the right one before writing a single line of work logic — changing it later means restructuring threading.
Class
Threading model
Use when
CoroutineWorker
Runs on Dispatchers.Default by default; fully suspend-compatible
You use coroutines — the standard choice for new code
Worker
Runs on WorkManager’s background thread pool (blocking)
Purely synchronous work, or wrapping a blocking Java library
ListenableWorker
Callback-based (returns ListenableFuture); no threading provided
Interop with Guava/RxJava, or when you need full threading control
UploadWorker.kt — CoroutineWorker with input data, retry, and output
classUploadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fundoWork(): Result {
// inputData is a Data object — key/value store with 10KB limitval fileUri = inputData.getString("FILE_URI")
?: returnResult.failure(
workDataOf("ERROR" to "Missing file URI")
)
return try {
// setProgress lets the UI observe intermediate statesetProgress(workDataOf("PROGRESS" to 0))
val uploadedUrl = uploadFile(fileUri) { progress ->
// called from within uploadFile as bytes are sent
runBlocking { setProgress(workDataOf("PROGRESS" to progress)) }
}
// Output data flows to the next worker in a chainResult.success(workDataOf("UPLOADED_URL" to uploadedUrl))
} catch (e: IOException) {
// runAttemptCount starts at 0; WorkManager increments before each retryif (runAttemptCount < 3) {
Result.retry() // WorkManager re-queues with backoff delay
} else {
Result.failure(workDataOf("ERROR" to e.message))
}
}
}
}
// ── WorkInfo.State lifecycle ──────────────────────────────────────────// ENQUEUED → waiting for constraints to be met// RUNNING → doWork() is executing right now// SUCCEEDED → Result.success() returned; terminal// FAILED → Result.failure() returned or max retries hit; terminal// CANCELLED → cancelWorkById/Tag called; terminal// BLOCKED → waiting for predecessor in a chain to finish
Constraints
Constraints let WorkManager defer execution until the device is in a state where the work can succeed efficiently. WorkManager monitors constraint satisfaction via system broadcast receivers — when all constraints are met simultaneously, the work is dispatched. If a constraint is violated mid-execution (the network drops while an upload is in progress), the worker is stopped and rescheduled.
Constraints — all options with context
val constraints = Constraints.Builder()
// Network type — the most commonly used constraint
.setRequiredNetworkType(NetworkType.UNMETERED)
// NetworkType.CONNECTED — any active network (Wi-Fi or data)// NetworkType.UNMETERED — Wi-Fi / Ethernet only; avoids user's data plan// NetworkType.NOT_ROAMING — connected but not on a roaming SIM// NetworkType.METERED — cellular only (rare; avoids Wi-Fi)// NetworkType.NOT_REQUIRED — default; run regardless of connectivity
.setRequiresBatteryNotLow(true) // defers when battery drops below ~15%
.setRequiresCharging(true) // only runs while plugged in
.setRequiresStorageNotLow(true) // defers when internal storage critically low
.setRequiresDeviceIdle(true) // API 23+ — device not actively used by user
.build()
// Typical profile for a media upload job:val uploadConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.build()
// Typical profile for a nightly cleanup / analytics flush:val nightlyConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.setRequiresCharging(true) // most users charge overnight
.build()
Building and enqueueing requests
OneTimeWorkRequest, PeriodicWorkRequest, and unique work
// ── One-time request with all options ────────────────────────────────val uploadRequest = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(uploadConstraints)
.setInputData(workDataOf("FILE_URI" to uri.toString()))
.setInitialDelay(10, TimeUnit.MINUTES) // don't start for at least 10 min
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL, // LINEAR also available30, TimeUnit.SECONDS // initial backoff; doubles each retry
)
.addTag("upload") // for bulk cancel/observe by tag
.build()
// ── Unique work — at most one instance with a given name ─────────────WorkManager.getInstance(context).enqueueUniqueWork(
"photo_upload",
ExistingWorkPolicy.KEEP, // KEEP / REPLACE / APPEND / APPEND_OR_REPLACE
uploadRequest
)
// KEEP — ignore new request if one is already pending/running// REPLACE — cancel existing, enqueue new (resets the work)// APPEND — run new after existing finishes (chain extension)// APPEND_OR_REPLACE — like APPEND, but replaces if existing is FAILED/CANCELLED// ── Periodic work — minimum interval is 15 minutes (OS enforced) ─────val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 1, repeatIntervalTimeUnit = TimeUnit.HOURS
)
.setConstraints(nightlyConstraints)
.setInitialDelay(1, TimeUnit.HOURS)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"hourly_sync",
ExistingPeriodicWorkPolicy.UPDATE, // UPDATE: keep existing run count, update params
syncRequest
)
// ── Observing state and progress ──────────────────────────────────────WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(uploadRequest.id)
.observe(lifecycleOwner) { info ->
when (info?.state) {
WorkInfo.State.RUNNING -> {
val progress = info.progress.getInt("PROGRESS", 0)
progressBar.progress = progress
}
WorkInfo.State.SUCCEEDED -> {
val url = info.outputData.getString("UPLOADED_URL")
showSuccess(url)
}
WorkInfo.State.FAILED -> {
val err = info.outputData.getString("ERROR")
showError(err)
}
else -> {}
}
}
Chaining work
Work chains define a dependency graph: each step only runs after its predecessor succeeds. The output Data of one worker is merged and passed as input to the next. Chains support both sequential steps and parallel fan-out/fan-in patterns. If any worker returns Result.failure(), all downstream work is cancelled automatically (unless you override this with setInputMerger or structure the chain differently).
🔗 Work chain — sequential steps, parallel fan-out, and merged fan-in
Work chains — sequential, parallel, input mergers
val wm = WorkManager.getInstance(context)
// ── Simple sequential chain ───────────────────────────────────────────
wm.beginWith(filterRequest)
.then(compressRequest)
.then(uploadRequest)
.then(notifyRequest)
.enqueue()
// ── Parallel fan-out merged into single next step ─────────────────────val parallelChain = wm.beginWith(listOf(compressRequest, watermarkRequest))
WorkContinuation
.combine(listOf(parallelChain))
.then(uploadRequest)
.enqueue()
// ── Input mergers — how parallel outputs combine for the next worker ──// OverwritingInputMerger (default): later workers' output overwrites earlier// ArrayCreatingInputMerger: same key → values collected into an arrayval uploadWithMerger = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputMerger(ArrayCreatingInputMerger::class)
// CompressWorker outputs "FILE_PATH" → "/a.jpg"// WatermarkWorker outputs "FILE_PATH" → "/b.jpg"// UploadWorker.inputData.getStringArray("FILE_PATH") → ["/a.jpg", "/b.jpg"]
.build()
// ── Continue on failure — run cleanup even if upload fails ────────────
wm.beginUniqueWork("upload_chain", ExistingWorkPolicy.REPLACE, uploadRequest)
.then(cleanupRequest) // runs even if uploadRequest FAILED
.enqueue()
// Note: CleanupWorker.inputData will have a "STOP_REASON" key you can inspect
Idempotency
WorkManager may run a worker more than once. A retry after a transient failure is expected, but there are subtler cases: a REPLACE policy that re-enqueues while the original is mid-flight, a race between two processes on a multi-process app, or rare WorkManager edge cases during DB migration. Every worker must be written so that running it twice produces the same result as running it once.
Use enqueueUniqueWork with KEEP so you never double-queue the same logical job.
Pass idempotency keys to server endpoints so duplicate uploads are silently ignored.
Use INSERT OR REPLACE / OnConflictStrategy.REPLACE for DB writes so re-syncing doesn’t create duplicate rows.
Check a “done” flag in Room or SharedPrefs at the start of doWork() and return Result.success() early if the work was already completed.
For file operations, write to a temp path and rename atomically — a partially written file from a killed worker won’t corrupt the destination.
Expedited work & long-running workers
Standard WorkManager work is deferrable. But sometimes work needs to start quickly and run to completion without the system killing it mid-way. WorkManager has two mechanisms for this depending on expected duration.
setExpedited() — fast-start short tasks
Expedited work (introduced in WorkManager 2.7 / API 31) tells the OS this job is important and should start as soon as possible, with higher priority than standard work. On API 31+ it maps to JobScheduler.setExpedited(). On older APIs WorkManager runs a short foreground service automatically to give the work a higher process priority.
Expedited work request
val expeditedRequest = OneTimeWorkRequestBuilder<SendMessageWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
// OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST// → fall back to regular work if expedited quota is exhausted// OutOfQuotaPolicy.DROP_WORK_REQUEST// → cancel the work entirely if quota is exhausted
.setInputData(workDataOf("MESSAGE_ID" to messageId))
.build()
// Expedited workers must override getForegroundInfo() for API < 31// WorkManager calls it to create the foreground service notificationclassSendMessageWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fungetForegroundInfo(): ForegroundInfo {
val notification = buildNotification("Sending message...")
returnForegroundInfo(NOTIFICATION_ID, notification)
}
override suspend fundoWork(): Result {
// fast, high-priority workreturnResult.success()
}
}
Long-running workers with setForeground()
For work that takes minutes rather than seconds — transcoding a video, downloading a large file, bulk processing — standard WorkManager gives the OS permission to kill the worker when memory pressure rises. The solution is to promote the worker to a foreground service mid-execution using setForeground(), which shows a persistent notification and keeps the process alive.
Long-running CoroutineWorker with setForeground and progress
classTranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fundoWork(): Result {
val videoUri = inputData.getString("VIDEO_URI") ?: returnResult.failure()
// Promote to foreground service — keeps process alive, shows notificationsetForeground(createForegroundInfo(0))
return try {
transcodeVideo(videoUri) { progress ->
// Update both the notification and the observable progress
runBlocking {
setForeground(createForegroundInfo(progress))
setProgress(workDataOf("PROGRESS" to progress))
}
}
Result.success()
} catch (e: Exception) {
Result.failure()
}
}
private funcreateForegroundInfo(progress: Int): ForegroundInfo {
val notification = NotificationCompat.Builder(applicationContext, "TRANSCODE_CHANNEL")
.setContentTitle("Transcoding video")
.setContentText("$progress% complete")
.setProgress(100, progress, false)
.setSmallIcon(R.drawable.ic_transcode)
.setOngoing(true)
.build()
returnForegroundInfo(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC // API 29+
)
}
}
ℹ️ Foreground Worker vs Foreground Service
A foreground worker is the right choice when the work has a clear completion condition and you want WorkManager’s retrying, constraints, and observability. A raw foreground service is better when work is open-ended with no fixed end (a music player, a location tracker, an ongoing call). If you find yourself wondering “should I use a foreground service or a WorkManager foreground worker?” — the answer is: does the work have a definite end? Yes → WorkManager. No → foreground service.
Hilt injection into Workers
By default, WorkManager instantiates workers using reflection with no constructor arguments beyond Context and WorkerParameters. To inject repositories, DAOs, or any other dependency, you need to replace WorkManager’s WorkerFactory with Hilt’s HiltWorkerFactory.
@HiltWorker — full setup
// 1. Annotate the Worker with @HiltWorker and @AssistedInject ─────────@HiltWorkerclassSyncWorker@AssistedInjectconstructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val userRepo: UserRepository, // injected by Hiltprivate val analyticsRepo: AnalyticsRepository
) : CoroutineWorker(context, params) {
override suspend fundoWork(): Result {
return try {
userRepo.syncFromServer()
analyticsRepo.flush()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
// 2. Initialize WorkManager with HiltWorkerFactory in Application ──────@HiltAndroidAppclassMyApp : Application(), Configuration.Provider {
@Injectlateinit var workerFactory: HiltWorkerFactoryoverride fungetWorkManagerConfiguration() =
Configuration.Builder()
.setWorkerFactory(workerFactory)
.setMinimumLoggingLevel(Log.DEBUG) // verbose logs during dev
.build()
}
// 3. Disable WorkManager's default initializer in AndroidManifest.xml ─// <provider// android:name="androidx.startup.InitializationProvider"// android:authorities="${applicationId}.androidx-startup"// tools:node="merge">// <meta-data android:name="androidx.work.WorkManagerInitializer"// tools:node="remove" />// </provider>
Testing Workers
WorkManager ships a dedicated test library (work-testing) that lets you run workers synchronously in unit tests without a real Android device or emulator. No background threads, no timing dependencies — workers run inline and return their Result immediately.
WorkManager testing — TestWorkerBuilder and WorkManagerTestInitHelper
// build.gradle// androidTestImplementation "androidx.work:work-testing:2.x.x"// ── Unit test with TestWorkerBuilder ─────────────────────────────────@RunWith(AndroidJUnit4::class)
classUploadWorkerTest {
private lateinit var context: Context@BeforefunsetUp() { context = ApplicationProvider.getApplicationContext() }
@TestfuntestUploadSuccess() {
val worker = TestWorkerBuilder<UploadWorker>(
context = context,
executor = Executors.newSingleThreadExecutor(),
inputData = workDataOf("FILE_URI" to "file://test.jpg")
).build()
val result = worker.doWork() // runs synchronouslyassertThat(result).isInstanceOf(Result.Success::class.java)
assertThat(
(result asResult.Success).outputData.getString("UPLOADED_URL")
).isNotNull()
}
@TestfuntestUploadRetryOnNetworkError() {
// inject a fake repo that throws IOException on first callval worker = TestWorkerBuilder<UploadWorker>(context, executor, inputData)
.setRunAttemptCount(0)
.build()
assertThat(worker.doWork()).isInstanceOf(Result.Retry::class.java)
}
}
// ── Integration test with WorkManagerTestInitHelper ──────────────────// Drives constraint satisfaction and time manually — no real scheduling@TestfuntestConstraintDrivenWork() {
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setExecutor(SynchronousExecutor())
.build()
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
val wm = WorkManager.getInstance(context)
val testDriver = WorkManagerTestInitHelper.getTestDriver(context)!!
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
wm.enqueue(request)
// Work is ENQUEUED but NOT running yet — network constraint not metassertThat(wm.getWorkInfoById(request.id).get()!!.state)
.isEqualTo(WorkInfo.State.ENQUEUED)
// Satisfy the constraint programmatically
testDriver.setAllConstraintsMet(request.id)
// Now work should be SUCCEEDEDassertThat(wm.getWorkInfoById(request.id).get()!!.state)
.isEqualTo(WorkInfo.State.SUCCEEDED)
}
JobScheduler
JobScheduler is the low-level system API that WorkManager delegates to on API 23+. It’s worth understanding deeply for two reasons: it explains WorkManager’s scheduling behaviour, and there are cases where you genuinely want to reach for it directly — when you’re building a system-level service, a library that can’t take a WorkManager dependency, or when you need trigger types WorkManager doesn’t expose (like setTriggerContentUri).
The core model: you define a JobInfo describing what to run and under what conditions, and hand it to JobScheduler.schedule(). The OS batches jobs across all apps to minimise wake-ups, then calls your JobService.onStartJob() when conditions are met. Critically, JobScheduler makes no guarantee that a job will ever run — if the device stays in Doze or the user restricts background activity, a job may never fire. WorkManager adds its own persistence layer on top to compensate.
JobService
A JobService is a Service subclass that the OS binds to when it wants your job to run. Both onStartJob and onStopJob are called on the main thread — you must dispatch work to a background thread immediately. Return true from onStartJob to signal that work is ongoing asynchronously; call jobFinished() when done.
SyncJobService.kt — with coroutine scope and proper lifecycle
classSyncJobService : JobService() {
// SupervisorJob so one failed child doesn't cancel other childrenprivate val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Called on main thread — return true if work is asyncoverride funonStartJob(params: JobParameters): Boolean {
scope.launch {
try {
performSync(params.extras) // PersistableBundle from JobInfojobFinished(params, false) // false = don't reschedule
} catch (e: IOException) {
jobFinished(params, true) // true = reschedule with backoff
}
}
return true// async work in progress
}
// Called when OS needs the job to stop (constraint violated, timeout hit)override funonStopJob(params: JobParameters): Boolean {
scope.coroutineContext.cancelChildren()
return true// true = reschedule; false = drop the job
}
override funonDestroy() { scope.cancel(); super.onDestroy() }
}
// AndroidManifest.xml — permission is mandatory// <service android:name=".SyncJobService"// android:permission="android.permission.BIND_JOB_SERVICE"// android:exported="true" />
val scheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) asJobSchedulerval component = ComponentName(this, SyncJobService::class.java)
// ── Periodic job with constraints ─────────────────────────────────────val periodicJob = JobInfo.Builder(JOB_ID_SYNC, component)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
.setRequiresCharging(true)
.setRequiresBatteryNotLow(true) // API 26+
.setRequiresDeviceIdle(false)
.setPeriodic(AlarmManager.INTERVAL_HOUR, 15 * 60 * 1000L) // period + flex window
.setPersisted(true) // survives reboot (needs RECEIVE_BOOT_COMPLETED)
.setBackoffCriteria(30_000L, JobInfo.BACKOFF_POLICY_EXPONENTIAL)
.build()
scheduler.schedule(periodicJob)
// ── One-time job with latency window — run between 1 and 5 minutes ──val delayedJob = JobInfo.Builder(JOB_ID_DELAYED, component)
.setMinimumLatency(60_000L) // don't start before 1 min
.setOverrideDeadline(300_000L) // must start by 5 min even if constraints unmet
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_CONNECTED)
.build()
// params.isOverrideDeadlineExpired in onStartJob() tells you if deadline forced it// ── Content URI trigger — fire when MediaStore data changes ──────────// Perfect for: reacting to new photos, document changes, media scansval mediaJob = JobInfo.Builder(JOB_ID_MEDIA, component)
.addTriggerContentUri(
JobInfo.TriggerContentUri(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS
)
)
.setTriggerContentMaxDelay(2_000L) // batch changes for 2s before firing
.setTriggerContentUpdateDelay(500L) // wait 500ms after last change before dispatching
.build()
// In onStartJob(): params.triggeredContentAuthorities and triggeredContentUris// tell you exactly what changed// ── Cancel jobs ───────────────────────────────────────────────────────
scheduler.cancel(JOB_ID_SYNC)
scheduler.cancelAll()
JobInfo trigger type
How it fires
Use for
setPeriodic(interval)
Repeats on interval; OS may batch within flex window
Regular background syncs
setMinimumLatency + setOverrideDeadline
Fires between the two bounds; deadline forces execution even if constraints unmet
Time-windowed one-shot jobs
setTriggerContentUri
Fires when a content provider URI subtree changes
Reacting to MediaStore, Contacts, or custom provider changes
No timing set
Fires as soon as all constraints are met
Constraint-gated one-shot work
⚠️ JobScheduler job timeout
The OS imposes a hard timeout on JobService execution. On API 26+ you get ~10 minutes before the system calls onStopJob(). On Android 14 (API 34)+, user-initiated jobs get up to 1 hour with setUserInitiated(true). For work that genuinely takes longer, use a WorkManager foreground worker or a bound foreground service — don’t fight the timeout.
AlarmManager
AlarmManager is the oldest background scheduling API and the only one backed by hardware. The device’s Real-Time Clock chip fires a signal to the kernel at the specified time regardless of whether the CPU is asleep, which is why AlarmManager can wake a sleeping device in a way that WorkManager and JobScheduler cannot.
But this power comes with responsibility. Exact alarms that wake the CPU are expensive — they prevent the processor from staying in low-power sleep states. Android has progressively restricted them: Doze mode in API 23 deferred most alarms, API 31 added the SCHEDULE_EXACT_ALARM permission, and API 33 added USE_EXACT_ALARM for a narrow set of use cases. The message from Google is clear: use exact alarms only for user-visible, time-specific events — not for background sync.
Clock types: RTC vs ELAPSED_REALTIME
Before picking a method, you need to choose the right clock. This affects what “when” means:
Clock type
Reference point
Affected by timezone change?
Use for
RTC
Wall-clock time (Unix epoch ms). System.currentTimeMillis()
Yes — changes if user changes timezone
User-visible alarms at a specific time of day: "remind me at 9am"
RTC_WAKEUP
Same as RTC, but wakes device if asleep
Yes
Same as RTC but must fire even if screen is off
ELAPSED_REALTIME
Time since last boot. SystemClock.elapsedRealtime()
No
Relative delays: "fire 30 minutes from now"
ELAPSED_REALTIME_WAKEUP
Same as ELAPSED_REALTIME, but wakes device
No
Precise countdown timers that must fire even if screen off
Alarm methods: exact vs inexact
Method
Exactness
Fires in Doze?
API
Use for
set()
Inexact — OS batches with other alarms
No
1+
Non-critical deferred triggers
setWindow()
Semi-exact — fires within a time window you specify
No
19+
Flexible reminders ("within the next 15 minutes")
setExact()
Exact — fires at the specified time
No — deferred during Doze
19+
Precise reminders outside Doze windows
setExactAndAllowWhileIdle()
Exact — fires even in Doze
Yes ✓
23+
Calendar events, alarms that must fire during Doze
setAlarmClock()
Exact — fires even in Doze; shows clock icon in status bar
Yes ✓
21+
User-visible wake-up alarms
AlarmManager — full implementation with API 31+ permission, reboot survival
classReminderScheduler(
private val context: Context,
private val alarmManager: AlarmManager = context.getSystemService(Context.ALARM_SERVICE) asAlarmManager
) {
funscheduleReminder(reminderId: Int, triggerAtMillis: Long, label: String) {
val pendingIntent = buildPendingIntent(reminderId, label)
when {
// API 31+: must check permission before scheduling exact alarmsBuild.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent
)
} else {
// Guide user to Settings → Apps → Special App Access → Alarms & Remindersval intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
.apply { data = Uri.parse("package:${context.packageName}") }
context.startActivity(intent)
}
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ->
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent
)
else ->
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
}
}
funcancelReminder(reminderId: Int) {
alarmManager.cancel(buildPendingIntent(reminderId, ""))
}
private funbuildPendingIntent(reminderId: Int, label: String): PendingIntent {
val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.ACTION_REMINDER"putExtra("REMINDER_ID", reminderId)
putExtra("LABEL", label)
}
// FLAG_IMMUTABLE: required for API 31+ for security// FLAG_UPDATE_CURRENT: update extras if this intent already existsreturnPendingIntent.getBroadcast(
context, reminderId, intent,
PendingIntent.FLAG_UPDATE_CURRENT orPendingIntent.FLAG_IMMUTABLE
)
}
}
// ── AlarmReceiver ─────────────────────────────────────────────────────classAlarmReceiver : BroadcastReceiver() {
override funonReceive(context: Context, intent: Intent) {
val reminderId = intent.getIntExtra("REMINDER_ID", -1)
val label = intent.getStringExtra("LABEL") ?: return// BroadcastReceiver has ~10 seconds of main-thread execution.// For quick work (fire a notification): do it here directly.// For longer work: enqueue a WorkManager OneTimeWorkRequest.showReminderNotification(context, reminderId, label)
}
}
// ── BootReceiver — re-schedule alarms after reboot ───────────────────// Alarms don't survive reboots. This re-registers them from your database.classBootReceiver : BroadcastReceiver() {
override funonReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return// Load pending reminders from Room, re-schedule each oneCoroutineScope(Dispatchers.IO).launch {
val reminders = ReminderRepository(context).getAllPending()
val scheduler = ReminderScheduler(context)
reminders.forEach { r -> scheduler.scheduleReminder(r.id, r.triggerAt, r.label) }
}
}
}
// AndroidManifest: <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>// <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> (API 31+)
⚠️ Never use setRepeating() for background sync
setRepeating() and setInexactRepeating() wake the CPU on every cycle. For anything resembling periodic background sync — data fetch, analytics flush, cache cleanup — use PeriodicWorkRequest in WorkManager instead. The OS can batch WorkManager’s jobs across apps; a repeating alarm fires regardless of system state.
Doze mode & App Standby
Doze mode is Android’s most impactful battery optimisation. Understanding it precisely tells you why certain work runs late or not at all in production, and which APIs actually pierce through it.
Full Doze vs Light Doze
Android distinguishes two levels. Full Doze activates when the device is stationary, unplugged, and the screen has been off for an extended period. Light Doze (Android 7.0+) activates when the screen is off and the device is unplugged but moving — like when it’s in your pocket. Light Doze applies most of the same restrictions but opens maintenance windows more frequently.
💤 Full Doze timeline — restrictions and maintenance windows
In full Doze the following are blocked or deferred until a maintenance window: all network access, CPU wakelocks, JobScheduler jobs, SyncAdapter syncs, and AlarmManager.set()/setExact() alarms. The only alarms that fire through Doze are setExactAndAllowWhileIdle() and setAlarmClock(). High-priority FCM messages also break through.
App Standby Buckets
Orthogonal to Doze, Android 9 (API 28) introduced App Standby Buckets. The OS classifies every app into one of five buckets based on how recently and frequently the user engaged with it. Apps in lower buckets get fewer job executions and alarm opportunities per day. WorkManager is aware of these buckets and adjusts its scheduling accordingly — but you can’t control which bucket you’re in. The only lever is getting the user to interact with your app more frequently.
Bucket
Condition
Job runs per day
Alarm quota
ACTIVE
App is currently in foreground or was used in the last few minutes
Unlimited
Unlimited
WORKING_SET
Used regularly — in the last few hours
~10 / day (generous)
10 / day
FREQUENT
Used at least every few days
~5 / day
5 / day
RARE
Used less than once a week
~1 / day
1 / day
RESTRICTED
Rarely used or flagged by system heuristics (API 30+)
~1 / day, further gated
1 / day, further delayed
🔧 Testing Doze and App Standby with adb
You don’t need to wait for a real device to go idle. Force Doze and bucket changes from a terminal:
adb commands — force Doze mode and App Standby bucket for testing
# ── Doze mode ─────────────────────────────────────────────────────────# Enable Doze (device must be unplugged in emulator or physical device)
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle enable
adb shell dumpsys deviceidle force-idle # skip the waiting phases# Check current Doze state
adb shell dumpsys deviceidle
# Step through Doze phases one at a time
adb shell dumpsys deviceidle step
# Exit Doze (re-plug)
adb shell dumpsys battery reset
# ── App Standby Buckets ───────────────────────────────────────────────# Force your app into a specific bucket
adb shell am set-standby-bucket com.your.package active
adb shell am set-standby-bucket com.your.package working_set
adb shell am set-standby-bucket com.your.package frequent
adb shell am set-standby-bucket com.your.package rare
adb shell am set-standby-bucket com.your.package restricted
# Check which bucket your app is currently in
adb shell am get-standby-bucket com.your.package
# ── WorkManager debugging ─────────────────────────────────────────────# Dump all pending WorkManager jobs and their states
adb shell dumpsys jobscheduler | grep -A5 com.your.package
# Trigger WorkManager's diagnostic dump
adb shell am broadcast -a androidx.work.diagnostics.REQUEST_DIAGNOSTICS \
-p com.your.package
Decision tree & comparison
The right mental model for picking between these three APIs is a decision tree, not a comparison table. Ask three questions in order:
Does the work need to fire at a specific wall-clock time? If yes → AlarmManager. If no → continue.
Does the work need to survive process death, device reboot, and be guaranteed to eventually run? If yes → WorkManager. If no → continue.
Do you need OS-level scheduling (no WorkManager dependency, content URI triggers, or extreme control over job priority)? If yes → JobScheduler. If no → WorkManager is still fine.
Capability
WorkManager
JobScheduler
AlarmManager
Guaranteed execution
✅ Yes — retries + persists in Room DB
❌ No
✅ Exact alarms yes; inexact — deferred
Survives process death
✅ Yes
✅ Yes (setPersisted)
❌ Must reschedule in BootReceiver
Survives device reboot
✅ Automatic
✅ setPersisted = true
❌ Must reschedule in BootReceiver
Network / charging constraints
✅ Yes
✅ Yes
❌ No
Chaining / work graphs
✅ Yes
❌ No
❌ No
Observable state (Flow/LiveData)
✅ WorkInfo + Flow
❌ No
❌ No
Expedited / high priority
✅ setExpedited()
✅ setExpedited() API 31+
✅ setAlarmClock()
Fires at exact wall-clock time
❌ Deferrable only
❌ Deferrable only
✅ setExact*()
Fires during Doze
❌ Deferred to maintenance window
❌ Deferred to maintenance window
✅ setExactAndAllowWhileIdle() only
Content URI trigger
❌ No
✅ setTriggerContentUri()
❌ No
Min periodic interval
15 minutes
15 minutes
No minimum
Needs special permission (modern API)
❌ None
❌ None
✅ SCHEDULE_EXACT_ALARM (API 31+)
Best for
Uploads, sync, compression, analytics
Content observers, custom system jobs
Alarms, calendar reminders, countdowns
🧠 The interview answer
When asked “how do you schedule background work in Android?” — lead with the problem, not the API. WorkManager is the default for anything deferrable: it persists work in a Room DB, delegates to JobScheduler on API 23+, survives reboots automatically, supports constraints and chaining, and exposes observable WorkInfo state. Use setExpedited() for fast-start work and setForeground() for long-running work. Use AlarmManager.setExactAndAllowWhileIdle() when you specifically need to fire at a wall-clock time even during Doze — user alarms, calendar events. Use JobScheduler directly only when you need setTriggerContentUri or are building a library without a WorkManager dependency. Never use repeating alarms for periodic sync — PeriodicWorkRequest is the right tool there. And if the device is in App Standby RARE or RESTRICTED bucket, even WorkManager will have limited execution opportunities per day — the best defence is giving users reasons to open the app.