Your app isn't open. You're not using it. And somehow it's still one of the biggest battery drains on the phone.
That sounds contradictory, but "not open" only means the app doesn't have a visible activity. It says very little about what the app is doing.
The process might still exist. A worker could be syncing data. An alarm could wake the device. A foreground service might be tracking location. Push messages could trigger network requests. Or the app could be running a coroutine loop that checks a server every few minutes because somebody needed a quick solution six months ago.
The confusing part is that these cases look similar in Android's battery screen. They're not.
Some background work is legitimate. A navigation app needs location while guiding the user. A podcast app needs to finish a download. A messaging app needs to react to incoming messages.
The real problem starts when an app performs work more often, for longer, or with stricter timing than the feature actually requires.
"Running in the Background" Can Mean Several Things
An Android application doesn't have one simple open or closed state.
Its process may remain cached after the user leaves the app. That doesn't mean it's actively burning battery. A cached process can sit in memory without using meaningful CPU time, and Android may remove it later when the system needs memory.
Process lifetime is not an execution contract. A Timer, Handler, thread, or coroutine inside that process isn't guaranteed to keep running. If the process goes away, so does that work.
At the same time, Android provides mechanisms that can schedule or allow work without a visible activity:
- WorkManager jobs
- Scheduled alarms
- Foreground services
- Bound or started services
- Broadcast handling
- Firebase Cloud Messaging delivery
- Location callbacks
- Media playback
- Bluetooth or connected-device communication
A service is also not automatically a background thread. By default, its callbacks run on the application's main thread. More importantly, creating a service doesn't exempt an app from background execution limits.
So when I investigate battery usage, I don't ask, "Was the app running?" I ask:
- What work was performed?
- What caused it to start?
- How often did it wake the CPU?
- How long did it run?
- Did it use location or the network?
- Did the user receive enough value to justify that cost?
Those questions usually point toward the actual bug.
Android Tries to Batch Work for a Reason
Android's power management is built around avoiding many small, scattered wakeups.
When a device enters Doze, the system restricts network access and defers regular jobs, syncs, and standard alarms. It periodically opens maintenance windows so apps can perform deferred work together. App Standby applies additional limits based on how recently and frequently the user interacts with an app.
Exact behavior depends on the Android version, device state, standby bucket, manufacturer, and whether the device is charging. Vendor-specific power management can add another layer.
This is why background code that appears reliable on a developer phone connected to USB may behave completely differently overnight on a real user's device.
It also explains why fighting the scheduler is usually the wrong fix. Requesting battery optimization exemptions, using exact alarms, or keeping a foreground service alive can make work run more often, but it transfers the cost directly to the user's battery.
Battery optimization exemptions should be reserved for cases where the core function genuinely can't work under normal power restrictions. They aren't a general reliability setting.
Polling Is Usually the First Suspect
A common implementation looks harmless:
scope.launch {
while (isActive) {
syncWithServer()
delay(5.minutes)
}
}
I've also seen the same design built with Timer, Handler.postDelayed(), RxJava intervals, or a sleeping thread.
This has two problems.
First, it's not durable. If the process dies, the loop disappears. If somebody puts it in a foreground service to prevent that, the app now keeps itself active indefinitely.
Second, the schedule ignores device conditions. It may run when there's no useful data, when the battery is low, or when a larger batch could have been sent later.
Frequent network requests are especially expensive. The request's CPU time may be short, but establishing connections, transferring headers, performing TLS work, parsing responses, and activating the cellular radio all add cost. Several tiny requests can be worse than one batched request.
For durable work that can run later, WorkManager is usually a better fit:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val syncRequest =
PeriodicWorkRequestBuilder<AccountSyncWorker>(6, TimeUnit.HOURS)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"account-sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
This is appropriate when the sync must eventually happen but doesn't need an exact execution time. WorkManager persists scheduled work, cooperates with system scheduling, supports constraints, and applies retry policies.
Periodic WorkManager requests have a minimum interval of 15 minutes, but that doesn't mean they'll run exactly every 15 minutes. Execution can be delayed and batched by the system. If a feature needs second-level timing, periodic work is the wrong abstraction.
Unique work also matters. Without it, opening the app repeatedly can accidentally enqueue several copies of the same sync.
Push Beats Polling When the Server Knows First
If the server already knows when data changes, repeatedly asking it for updates wastes power.
FCM can notify the app that new data is available. The app can then fetch only what it needs. This changes the pattern from hundreds of empty requests to a small number of meaningful requests.
Normal-priority FCM messages may be delayed during Doze. High-priority messages are intended for time-sensitive, user-visible events. They should not be used as a hidden heartbeat. Misusing high priority can lead to delivery being deprioritized, and it still doesn't justify long background execution.
A useful messaging pattern is:
- The server sends a push indicating that data changed.
- The app performs a small, bounded update.
- Longer durable work is handed to WorkManager.
- Duplicate updates are collapsed using IDs or timestamps.
If the message isn't time-sensitive, letting Android batch the work is usually fine.
Alarms Are Not a General Background Scheduler
AlarmManager makes sense when time itself is part of the product requirement, such as an alarm clock or a user-created reminder.
It isn't a good replacement for a custom polling loop.
Wakeup alarms can wake the CPU while the device is asleep. Exact alarms are particularly costly because they reduce the system's ability to batch work. Modern Android versions also restrict exact alarm access, and apps should request it only when precise timing is central to the feature.
For a periodic database cleanup, upload, or refresh, use WorkManager. For "notify me at 7:30 AM," an alarm may be appropriate.
The difference is user intent and timing precision, not which API is easiest to call.
Location Can Drain a Battery Fast
Background location has a very visible cost because it can involve GPS, Wi-Fi scanning, cellular positioning, sensors, and CPU processing.
The dangerous configuration is usually some combination of:
- High accuracy
- Short update intervals
- No minimum displacement
- Updates continuing after the feature ends
- A foreground service that never stops
- Uploading every location point immediately
A fitness tracker or active navigation session may justify frequent updates. A weather app usually doesn't.
For less urgent features, developers can lower accuracy, increase intervals, request batched updates, use geofencing, or refresh location only when the user opens the relevant screen. Location callbacks should be removed as soon as they're no longer needed.
Background location access is also restricted and policy-sensitive on modern Android. A permission grant doesn't mean continuous tracking is automatically a good design.
Wake Locks and CPU Wakeups
When the screen is off, the CPU can enter low-power states. A partial wake lock asks the system to keep the CPU running.
That can be valid during a short critical operation, but an unreleased wake lock is one of the fastest ways to produce obvious drain.
Wake locks should be narrowly scoped and have a timeout:
val wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"${context.packageName}:upload"
)
wakeLock.acquire(2 * 60 * 1000L)
try {
uploadPendingData()
} finally {
if (wakeLock.isHeld) {
wakeLock.release()
}
}
Even this should prompt a design question. WorkManager and other system-managed components often handle the required wakefulness themselves. Adding another wake lock may be unnecessary.
Also, wake lock duration isn't the only useful metric. An app can cause serious drain through thousands of brief CPU wakeups. A five-second task once per hour may be fine. A 100-millisecond task every ten seconds may prevent the device from spending enough time asleep.
Count wakeups, not just total execution time.
Foreground Services Are for User-Visible Ongoing Work
A foreground service raises the process's importance and displays an ongoing notification. It makes sense for work the user actively expects to continue, including:
- Turn-by-turn navigation
- Media playback
- An active workout
- A user-started file transfer
- Connected-device communication
It shouldn't exist only to keep a process alive or preserve a polling loop.
Modern Android versions restrict when an app can start a foreground service from the background. They also require appropriate foreground service types and, in some cases, related permissions. These rules have become stricter across releases.
Even when starting one is technically allowed, the service should have a clear lifecycle. Start it because the user began an ongoing operation. Stop it when that operation ends.
A permanent notification saying "App is running" is usually evidence that the service serves the implementation rather than the user.
How I Track Down Background Battery Usage
Android's Battery usage screen is a starting point, not a profiler. Its numbers are estimates attributed over a time window, and presentation varies across devices. A high background percentage tells me where to look, but not which line of code is responsible.
For controlled testing, I reset battery statistics first:
adb shell dumpsys batterystats --reset
Then I unplug the device, run one specific scenario, leave it idle, and collect a bug report:
adb bugreport battery-test.zip
The bug report and batterystats data can reveal wake locks, jobs, alarms, network activity, and periods when the device remained awake. Battery Historian can make long sessions easier to inspect. Android Studio's Power Profiler is useful for correlating device power behavior with app activity on supported devices.
I also inspect the scheduler rather than assuming my code ran when requested:
adb shell dumpsys jobscheduler
adb shell dumpsys alarm
adb shell dumpsys deviceidle
These outputs are noisy, so I search for the package name and compare timestamps with application logs.
To test Doze behavior, I use a physical device, turn off the screen, disconnect power, and force idle when appropriate:
adb shell dumpsys deviceidle force-idle
After the test:
adb shell dumpsys deviceidle unforce
Forced idle isn't a perfect simulation of hours of normal use, but it quickly exposes code that assumes unrestricted networking or exact scheduling.
Useful application metrics include:
- Worker starts, finishes, retries, and cancellation reasons
- Foreground service start and stop times
- Network request counts and transferred bytes
- Location request duration and configuration
- Wake lock acquisition duration
- Push delivery and processing timestamps
- Sync triggers and the amount of useful data changed
Logging every five seconds can itself distort a test, so I keep instrumentation lightweight.
Normal Work Versus Real Drain
I don't classify an app as wasteful merely because it does something in the background.
A messaging app processing a few pushes is normal. A navigation app using location during an active trip is normal. A backup app uploading a large file after the user requested it may consume substantial battery, but the work has a clear purpose.
The suspicious cases look different:
- A process is kept important without active user-facing work.
- The CPU wakes frequently to discover there is nothing to do.
- Network requests return unchanged data most of the time.
- Location continues after the related feature ends.
- Failed jobs retry aggressively without backoff.
- A foreground service exists only to avoid process death.
- Several schedulers trigger duplicate copies of the same task.
The best metric is often useful work per wakeup. If most wakeups produce no visible state change, no new data, and no completed user request, the schedule is probably too aggressive.
Test the Design, Not Just the Code
Battery testing needs a real device. Emulators are useful for behavior and API testing, but they don't reproduce real radios, thermal conditions, standby behavior, or manufacturer power policies.
I test a release-like build under several conditions:
- Screen on and screen off
- Wi-Fi and cellular data
- Good and poor connectivity
- Doze and App Standby
- Low battery
- Server failures and repeated retries
- App opened frequently and left unused overnight
- Reboot followed by pending scheduled work
I compare against a baseline with the feature disabled. Battery percentage alone is too coarse for short tests, so I also inspect wakeups, wake lock time, job frequency, network transfers, and foreground service duration.
The fixes are usually straightforward once the trigger is visible: replace polling with push, batch writes, add constraints, use exponential backoff, deduplicate work, lower location accuracy, stop services promptly, and remove wake locks that the platform scheduler already handles.
Android isn't randomly killing apps, and background battery drain isn't caused by one API. The system manages processes and limits execution according to device state, app state, OS version, and manufacturer policy.
Our job is to describe background work honestly. If it can wait, schedule it as deferrable work. If the server knows when something changes, use push. If timing must be exact, make sure the user actually asked for that precision. If work must remain active and visible, use a foreground service with a real stop condition.
And if a five-minute loop feels like the easiest solution, that's usually the moment to step back and rethink the design.
Top comments (0)