⚙️ 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.

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
WorkManager Deferrable + Guaranteed ✅ Survives process death ✅ Survives device reboot ✅ Network / charging constraints ✅ Chainable work graphs ✅ Observable WorkInfo state ✅ Expedited & foreground workers ❌ Exact timing Upload, sync, compress, report Uses JobScheduler internally (API 23+) JobScheduler System-managed, no guarantee ✅ Battery & network aware ✅ Survives reboot (setPersisted) ✅ Content URI trigger ✅ Override deadline ❌ No chaining ❌ No guaranteed execution ❌ Exact timing Custom low-level job services Direct OS scheduling API (API 21+) AlarmManager Time-based, exact timing ✅ Fires at exact wall-clock time ✅ Can wake device from sleep ✅ Fires during Doze (exact) ❌ No constraints ❌ No chaining ❌ Must reschedule after reboot ❌ Needs permission (API 31+) Reminders, alarms, calendar Backed by hardware RTC chip
✅ 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
Your App enqueue(request) WorkManager validates constraints persists to Room DB Room DB (work_spec) Scheduler API 23+ → JobScheduler API 14-22 → AlarmMgr WorkerFactory instantiates Worker injects dependencies Worker doWork() On reboot: Room DB re-read → reschedule all pending work automatically

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.

ClassThreading modelUse when
CoroutineWorkerRuns on Dispatchers.Default by default; fully suspend-compatibleYou use coroutines — the standard choice for new code
WorkerRuns on WorkManager’s background thread pool (blocking)Purely synchronous work, or wrapping a blocking Java library
ListenableWorkerCallback-based (returns ListenableFuture); no threading providedInterop with Guava/RxJava, or when you need full threading control
UploadWorker.kt — CoroutineWorker with input data, retry, and output
class UploadWorker( context: Context, params: WorkerParameters ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { // inputData is a Data object — key/value store with 10KB limit val fileUri = inputData.getString("FILE_URI") ?: return Result.failure( workDataOf("ERROR" to "Missing file URI") ) return try { // setProgress lets the UI observe intermediate state setProgress(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 chain Result.success(workDataOf("UPLOADED_URL" to uploadedUrl)) } catch (e: IOException) { // runAttemptCount starts at 0; WorkManager increments before each retry if (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 available 30, 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
FilterWorker select images CompressWorker parallel WatermarkWorker parallel UploadWorker waits for both NotifyWorker push notification beginWith(filter) beginWith([compress, watermark]) combine() → .then(upload) .then(notify).enqueue()
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 array val 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.


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 notification class SendMessageWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { override suspend fun getForegroundInfo(): ForegroundInfo { val notification = buildNotification("Sending message...") return ForegroundInfo(NOTIFICATION_ID, notification) } override suspend fun doWork(): Result { // fast, high-priority work return Result.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
class TranscodeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { override suspend fun doWork(): Result { val videoUri = inputData.getString("VIDEO_URI") ?: return Result.failure() // Promote to foreground service — keeps process alive, shows notification setForeground(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 fun createForegroundInfo(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() return ForegroundInfo( 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 ───────── @HiltWorker class SyncWorker @AssistedInject constructor( @Assisted context: Context, @Assisted params: WorkerParameters, private val userRepo: UserRepository, // injected by Hilt private val analyticsRepo: AnalyticsRepository ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { return try { userRepo.syncFromServer() analyticsRepo.flush() Result.success() } catch (e: Exception) { Result.retry() } } } // 2. Initialize WorkManager with HiltWorkerFactory in Application ────── @HiltAndroidApp class MyApp : Application(), Configuration.Provider { @Inject lateinit var workerFactory: HiltWorkerFactory override fun getWorkManagerConfiguration() = 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) class UploadWorkerTest { private lateinit var context: Context @Before fun setUp() { context = ApplicationProvider.getApplicationContext() } @Test fun testUploadSuccess() { val worker = TestWorkerBuilder<UploadWorker>( context = context, executor = Executors.newSingleThreadExecutor(), inputData = workDataOf("FILE_URI" to "file://test.jpg") ).build() val result = worker.doWork() // runs synchronously assertThat(result).isInstanceOf(Result.Success::class.java) assertThat( (result as Result.Success).outputData.getString("UPLOADED_URL") ).isNotNull() } @Test fun testUploadRetryOnNetworkError() { // inject a fake repo that throws IOException on first call val 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 @Test fun testConstraintDrivenWork() { 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 met assertThat(wm.getWorkInfoById(request.id).get()!!.state) .isEqualTo(WorkInfo.State.ENQUEUED) // Satisfy the constraint programmatically testDriver.setAllConstraintsMet(request.id) // Now work should be SUCCEEDED assertThat(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
class SyncJobService : JobService() { // SupervisorJob so one failed child doesn't cancel other children private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // Called on main thread — return true if work is async override fun onStartJob(params: JobParameters): Boolean { scope.launch { try { performSync(params.extras) // PersistableBundle from JobInfo jobFinished(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 fun onStopJob(params: JobParameters): Boolean { scope.coroutineContext.cancelChildren() return true // true = reschedule; false = drop the job } override fun onDestroy() { scope.cancel(); super.onDestroy() } } // AndroidManifest.xml — permission is mandatory // <service android:name=".SyncJobService" // android:permission="android.permission.BIND_JOB_SERVICE" // android:exported="true" />

JobInfo — all trigger types

JobInfo.Builder — constraints, timing, content triggers
val scheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler val 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 scans val 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 typeHow it firesUse for
setPeriodic(interval)Repeats on interval; OS may batch within flex windowRegular background syncs
setMinimumLatency + setOverrideDeadlineFires between the two bounds; deadline forces execution even if constraints unmetTime-windowed one-shot jobs
setTriggerContentUriFires when a content provider URI subtree changesReacting to MediaStore, Contacts, or custom provider changes
No timing setFires as soon as all constraints are metConstraint-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 typeReference pointAffected by timezone change?Use for
RTCWall-clock time (Unix epoch ms). System.currentTimeMillis()Yes — changes if user changes timezoneUser-visible alarms at a specific time of day: "remind me at 9am"
RTC_WAKEUPSame as RTC, but wakes device if asleepYesSame as RTC but must fire even if screen is off
ELAPSED_REALTIMETime since last boot. SystemClock.elapsedRealtime()NoRelative delays: "fire 30 minutes from now"
ELAPSED_REALTIME_WAKEUPSame as ELAPSED_REALTIME, but wakes deviceNoPrecise countdown timers that must fire even if screen off

Alarm methods: exact vs inexact

MethodExactnessFires in Doze?APIUse for
set()Inexact — OS batches with other alarmsNo1+Non-critical deferred triggers
setWindow()Semi-exact — fires within a time window you specifyNo19+Flexible reminders ("within the next 15 minutes")
setExact()Exact — fires at the specified timeNo — deferred during Doze19+Precise reminders outside Doze windows
setExactAndAllowWhileIdle()Exact — fires even in DozeYes ✓23+Calendar events, alarms that must fire during Doze
setAlarmClock()Exact — fires even in Doze; shows clock icon in status barYes ✓21+User-visible wake-up alarms
AlarmManager — full implementation with API 31+ permission, reboot survival
class ReminderScheduler( private val context: Context, private val alarmManager: AlarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager ) { fun scheduleReminder(reminderId: Int, triggerAtMillis: Long, label: String) { val pendingIntent = buildPendingIntent(reminderId, label) when { // API 31+: must check permission before scheduling exact alarms Build.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 & Reminders val 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) } } fun cancelReminder(reminderId: Int) { alarmManager.cancel(buildPendingIntent(reminderId, "")) } private fun buildPendingIntent(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 exists return PendingIntent.getBroadcast( context, reminderId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) } } // ── AlarmReceiver ───────────────────────────────────────────────────── class AlarmReceiver : BroadcastReceiver() { override fun onReceive(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. class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != Intent.ACTION_BOOT_COMPLETED) return // Load pending reminders from Room, re-schedule each one CoroutineScope(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
Active Screen off Pending Doze active Network blocked · Wakelocks ignored · JobScheduler deferred · set() / setExact() alarms deferred ▲ Maintenance windows (periodic, ~every few hours) WorkManager deferred jobs, alarms set via set()/setExact() fire here Always fires in Doze: setExactAndAllowWhileIdle() · setAlarmClock() · High-priority FCM push

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.

BucketConditionJob runs per dayAlarm quota
ACTIVEApp is currently in foreground or was used in the last few minutesUnlimitedUnlimited
WORKING_SETUsed regularly — in the last few hours~10 / day (generous)10 / day
FREQUENTUsed at least every few days~5 / day5 / day
RAREUsed less than once a week~1 / day1 / day
RESTRICTEDRarely used or flagged by system heuristics (API 30+)~1 / day, further gated1 / 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:

  1. Does the work need to fire at a specific wall-clock time? If yes → AlarmManager. If no → continue.
  2. Does the work need to survive process death, device reboot, and be guaranteed to eventually run? If yes → WorkManager. If no → continue.
  3. 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.
CapabilityWorkManagerJobSchedulerAlarmManager
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 interval15 minutes15 minutesNo minimum
Needs special permission (modern API)❌ None❌ None✅ SCHEDULE_EXACT_ALARM (API 31+)
Best forUploads, sync, compression, analyticsContent observers, custom system jobsAlarms, 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.