How do you optimize background work for battery — Doze mode, WorkManager constraints, JobScheduler?
Android throttles background work aggressively once a device is idle, so the real skill isn't fighting Doze — it's declaring constraints precise enough that the system schedules your work for free.
LEARN BY DOINGPut your intuition to work3 steps
How do you optimize background work for battery — Doze mode, WorkManager constraints, JobScheduler?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why the platform fights you on purpose
Every background service, polling loop, or partial WakeLock an app holds keeps the CPU and radio awake, and radio wakeups are disproportionately expensive — ramping a cellular or WiFi radio up from idle costs far more energy than the few kilobytes of data it then transfers, an effect commonly called "tail energy." Android's answer, starting with Marshmallow and getting sharper every release since, is to stop trusting apps to self-regulate and instead defer, batch, and throttle background work at the OS level. Understanding that shift — from "apps schedule themselves" to "apps declare what they need and the OS decides when" — is the entire mental model behind Doze, App Standby, and WorkManager.
#Doze and App Standby: two independent throttles
Doze mode engages device-wide when the screen is off, the device is unplugged, and it's been stationary for a while. It cycles through increasingly long "napping" intervals punctuated by short maintenance windows; outside those windows, network access is suspended, most wakelocks are ignored, AlarmManager alarms are deferred (setExact/setExactAndAllowWhileIdle are the narrow exceptions for genuinely time-critical, user-visible alarms), and JobScheduler/WorkManager jobs wait for the next window. App Standby buckets are a separate, per-app throttle introduced in Android 9: based on how recently and how often a user actually interacts with an app, the system places it into ACTIVE, WORKING_SET, FREQUENT, RARE, or RESTRICTED, and each bucket caps how often that specific app's jobs and high-priority FCM messages are allowed to run — independent of whether the device is in Doze at all. An app the user opens daily gets far more background latitude than one they installed and never revisited.
#JobScheduler vs. WorkManager
JobScheduler (API 21+) is the platform-level API for constraint-based deferred work: you describe a JobInfo with constraints — network type, charging state, device idle — and the system batches your job alongside other apps' jobs to minimize how often it has to wake the radio at all. It's powerful but has two real limitations that show up constantly in practice: it doesn't exist before API 21, and a job's persistence guarantees around process death and reboot are easy to get wrong by hand. WorkManager is Jetpack's answer — it sits on top of JobScheduler on modern API levels and falls back to AlarmManager + a BroadcastReceiver on older ones, persists every enqueued request in an internal Room database so work survives process death and even a reboot, and gives you one consistent API regardless of which backend is actually running. Unless you have a very specific reason to hand-roll JobScheduler (usually: needing a platform feature WorkManager hasn't wrapped yet), WorkManager is the default answer in an interview and in practice.
#Constraints, cadence, and the honesty they enforce
A Constraints object — setRequiredNetworkType, requiresCharging, requiresBatteryNotLow, requiresStorageNotLow, requiresDeviceIdle — is evaluated as a conjunction: all of them must hold before the OS will even attempt to run the work, and if a constraint stops holding mid-run, the work is stopped and rescheduled. This is the actual lever an interviewer is probing for: instead of writing your own polling loop and hoping it behaves, you declare exactly what conditions make the work acceptable, and the system — which sees every other app's constraints too — decides the cheapest moment to satisfy all of them at once, often batching several apps' network work into a single radio wakeup. PeriodicWorkRequest has a hard floor of 15 minutes between runs regardless of what you configure lower, specifically to stop apps from turning "periodic" into "constant." When you genuinely need something to run soon and can't wait for a Doze maintenance window, setExpedited() (WorkManager 2.7+) requests priority scheduling — backed by JobScheduler's expedited jobs on API 31+, or a short-lived foreground service below that — subject to a system-wide quota, so it's for "the user just tapped send" moments, not a substitute for a real schedule.
#What this looks like for a feed app specifically
The single highest-leverage battery habit for a content app is batching: sync a batch of pending changes in one request on a requiresBatteryNotLow + UNMETERED-when-possible periodic job rather than making many small calls throughout the day, since each call's radio ramp-up dwarfs the transfer itself. Defer anything non-urgent (image prefetch for tomorrow's session, analytics upload, cache warm-up) to requiresCharging = true combined with UNMETERED, so it only runs when the user is plugged in and off cellular — free from the user's perspective. Reserve real-time delivery for FCM rather than polling; a high-priority push can briefly wake the app even during Doze, but relying on that heavily gets an app throttled by App Standby, so it should be reserved for genuinely time-sensitive events, not routine sync. And always pair setBackoffCriteria(BackoffPolicy.EXPONENTIAL, ...) with retryable work — a naive linear or immediate retry on a flaky network is exactly the hot-loop behavior Doze and App Standby exist to shut down.
class FeedSyncWorker(context: Context,params: WorkerParameters,) : CoroutineWorker(context, params) {override suspend fun doWork(): Result = try {val pendingChanges = feedRepository.pullPendingChanges()feedApi.syncBatch(pendingChanges) // one request for the whole batch, not one per changeResult.success()} catch (e: IOException) {Result.retry() // transient network failure — let backoff handle the delay} catch (e: HttpException) {if (e.code() in 500..599) Result.retry() else Result.failure()}}// Routine sync: batched, deferred, only when it costs the user nothing.val syncConstraints = Constraints.Builder().setRequiredNetworkType(NetworkType.UNMETERED).setRequiresBatteryNotLow(true).build()val periodicSync = PeriodicWorkRequestBuilder<FeedSyncWorker>(repeatInterval = 15, TimeUnit.MINUTES, // the OS floor — cannot go lower).setConstraints(syncConstraints).setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS).build()WorkManager.getInstance(context).enqueueUniquePeriodicWork("feed-background-sync",ExistingPeriodicWorkPolicy.KEEP,periodicSync,)// "The user just posted" — urgent but short-lived, worth spending expedited quota on.val expeditedUpload = OneTimeWorkRequestBuilder<FeedSyncWorker>().setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST).setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()).build()WorkManager.getInstance(context).enqueue(expeditedUpload)
🧠 Doze & WorkManager check
0/3 · 0/3 answered1. An app schedules a PeriodicWorkRequest with a 5-minute repeatInterval. What actually happens?
2. Why is WorkManager generally preferred over calling JobScheduler directly?
3. Why does batching ten small sync calls into one request usually save more battery than the byte count alone would suggest?