If your app is killing batteries or bogging down phones, it’s almost always because of inefficient background execution. Getting this right directly affects your app’s retention and performance metrics, so developers have to ensure their apps run efficiently without being resource hogs.
Key Takeaways
- Use WorkManager for any background task you can defer. It’s efficient and designed to respect system-level battery optimizations.
- On older Android versions, use JobScheduler or Firebase JobDispatcher for flexible scheduling of non-critical work.
- Reserve Foreground Services for active, user-facing tasks like music playback or navigation, and always show a persistent notification.
- Constantly use the Android Studio Profiler to find and fix battery and CPU hogs in your app’s performance profile.
- Test your app in low-resource situations with Android Debug Bridge (ADB) commands and emulators so you can find problems before your users do.
1. Understand Android’s Background Execution Policies
Android’s approach to background work has gotten way stricter over the years to save battery and improve the user experience. We’ve gone from Doze mode in Android 6.0 (Marshmallow), to the background execution limits in 8.0 (Oreo), and now the App Standby Buckets in 9.0 (Pie) and later, all of which aggressively clamp down on what your app can do when it’s not on screen. First, you have to internalize these rules. For instance, if you launch a background service on Android 8.0 or higher, the OS will kill it in minutes unless you promote it to a foreground service (which needs a notification). Ignoring these platform changes causes app crashes and frustrated users. Pro Tip: Always, always target the latest Android API level. As of August 2023, Google Play requires new apps to target Android 13 (API level 33), and updates will need to target Android 14 (API level 34) by August 2024, per the official Android Developers Blog (https://android-developers.googleblog.com/2023/04/new-target-api-level-requirements-for-android-34.html). This forces your app to be built against the most current system behaviors.
2. Implement WorkManager for Deferrable Tasks
For any background work that can wait, WorkManager is your default choice. Part of Android Jetpack, it handles all the compatibility headaches across different Android versions, so your tasks run reliably. It’s basically a smart wrapper around `JobScheduler`, `Firebase JobDispatcher`, and `AlarmManager` so you don’t have to write the boilerplate yourself. To get it working:
- Add the WorkManager Dependency: In your app’s `build.gradle` file, drop this line into your `dependencies` block:
implementation "androidx.work:work-runtime-ktx:2.9.0"(But really, always check the official Android Developers documentation (https://developer.android.com/topic/libraries/architecture/workmanager) for the latest stable version number.)
- Define Your Worker: Make a class that extends `Worker` and put your background logic inside the `doWork()` method. This is where the actual work gets done.
class MyUploadWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { override fun doWork(): Result { // Your task goes here, e.g., upload some data return Result.success() } } - Create a Work Request: You need to decide if this is a one-off job or something that needs to repeat.
- One-time work:
val uploadWorkRequest: WorkRequest = OneTimeWorkRequestBuilder<MyUploadWorker>() .setConstraints(Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build()) .build() - Periodic work:
val periodicUploadRequest = PeriodicWorkRequestBuilder<MyUploadWorker>(15, TimeUnit.MINUTES) .setConstraints(Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build()) .build()Note that periodic work can’t run more frequently than every 15 minutes.
- One-time work:
- Enqueue the Work Request:
WorkManager.getInstance(applicationContext).enqueue(uploadWorkRequest)
WorkManager is smart enough to handle retries and persistence across reboots, and it respects system constraints like battery level and network status. This is a complete shift from the old, fragile service-based patterns. Common Mistake: Using `AlarmManager` for deferrable work that doesn’t need to be exact. `AlarmManager` should only be for things like an actual alarm clock or calendar event where timing is everything. For everything else, WorkManager is way more efficient and won’t kill the battery.
3. Use Foreground Services for Immediate, User-Aware Tasks
Sometimes a task *can’t* be deferred. Think music playback, GPS tracking during a run, or recording a call. For that, you have to use a Foreground Service. This is a type of service that the user is actively aware of, so the system won’t kill it for resources unless things are absolutely critical. The trade-off is that you *must* display a persistent notification to the user showing that your app is doing something. To set one up:
- Declare the Service in `AndroidManifest.xml`:
<service android:name=".MyForegroundService" android:foregroundServiceType="mediaPlayback" />That `android:foregroundServiceType` attribute is required starting in Android 10 (API 29). You have to declare what you’re doing, like `location`, `mediaPlayback`, `camera`, `microphone`, or `connectedDevice`.
- Start the Service and Display Notification: From your `Activity` or `Fragment`, kick off the service.
val serviceIntent = Intent(this, MyForegroundService::class.java) ContextCompat.startForegroundService(this, serviceIntent)Then, inside your service’s `onCreate()` or `onStartCommand()`, you have a 5-second window to call `startForeground()`.
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val notification = createNotification() // Build your notification here startForeground(NOTIFICATION_ID, notification) // ... now do your ongoing work ... return START_STICKY }The notification ID has to be a unique integer. Your `createNotification()` method will use `NotificationCompat.Builder` to construct the actual notification. The notification stays visible as long as the service is running.
- Stop the Service: Once the job is done, you have to clean up after yourself by calling `stopForeground(true)` and then `stopSelf()`.
stopForeground(true) // This removes the notification stopSelf() // This stops the service itself
If you don’t call `startForeground()` within that five-second time limit, your app *will* crash on newer Android versions with a `ForegroundServiceDidNotStartInTimeException`. That’s not a suggestion. It’s a hard requirement. Pro Tip: Don’t abuse Foreground Services. They’re a resource drain and constantly visible to the user, so misusing them is a great way to get bad reviews and uninstalls. If the work can be deferred, just use WorkManager.
4. Optimize Broadcast Receivers and Implicit Broadcasts
Android 8.0 (Oreo) really cracked down on implicit broadcasts. A bunch of system-wide broadcasts that used to get sent to every app that was listening are now heavily restricted. The `CONNECTIVITY_ACTION` broadcast, for example, is no longer delivered to receivers declared in your manifest. To adapt:
- Register Receivers Programmatically: If you only need to listen for a broadcast while your app’s UI is visible, don’t declare the `BroadcastReceiver` in the manifest. Instead, register it in your code with `Context.registerReceiver()` and be sure to unregister it when you’re done.
- Use WorkManager for Network Changes: If you need your app to do something in the background when network connectivity changes, don’t try to listen for the old broadcast. The right way is to use WorkManager’s `setRequiredNetworkType()` constraint. It’s simpler and works across all modern Android versions.
- Targeted Broadcasts: If you’re sending your own custom broadcasts between components in your app, always make them explicit by setting a target package with `Intent.setPackage()`. This is faster and more secure than yelling into the void and letting the system figure it out.
These changes force you to be much more deliberate about how your app reacts to system events, which directly translates to better battery life for the user.
5. Profile and Debug Background Behavior
You can’t fix what you can’t see, which makes profiling your app’s background behavior non-negotiable for finding and squashing performance problems. The Android Studio Profiler is your best friend here. Here’s a quick workflow:
- Connect Device/Emulator: Fire up your app on a real device (preferred) or an emulator.
- Open Profiler: In Android Studio, go to `View` > `Tool Windows` > `Profiler`.
- Select Process: Pick your app from the process list.
- Monitor CPU, Memory, Network, and Energy: The profiler gives you live graphs. The “Energy” profiler is especially useful because it combines CPU, network, and location sensor data to give you an estimate of battery impact.
- Record Traces: For a deep dive, record a CPU trace. Start the recording, push your app to the background, let some background work run, then stop the recording. Now you can analyze the trace for long-running methods, extra wake locks, or weird CPU spikes that shouldn’t be there.
- Analyze Network Traffic: The Network profiler will show you every single network request your app makes, even from background jobs. Use it to spot network calls that are happening too often or sending too much data. Identify unnecessary network calls.
Don’t stop with the Profiler. Get comfortable with ADB commands for a lower-level view:
- `adb shell dumpsys battery`: Dumps a massive report on what’s using the battery.
- `adb shell dumpsys jobscheduler`: Shows you every job your app has scheduled and its current state.
- `adb shell dumpsys usagestats`: Gives you stats on how long your app has been in different states (foreground, background, etc.).
Common Mistake: Only testing on your high-end flagship phone. An app that runs great on a Pixel with tons of RAM can easily crash or become a battery hog on a cheaper, older device. Test across a range of hardware. Seriously.
6. Manage Wake Locks and Alarms Prudently
Poorly managed Wake Locks are notorious battery killers. They stop the device from entering a low-power sleep state, and if you acquire them too often or forget to release them, you’ll drain the battery fast. To handle them correctly:
- Acquire Only When Necessary: Only grab a `PowerManager.WakeLock` when it’s absolutely unavoidable to finish a critical task.
- Release Promptly: The instant the work is done, release the lock. The safest way to do this is with a `try-finally` block, which guarantees the lock is released even if your code throws an exception.
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MyApp::MyWakeLockTag") wakeLock.acquire(10 60 1000L /*10 minutes*/) // Use a timeout! try { // Do your critical work here } finally { wakeLock.release() } - Avoid Full Wake Locks: A `PARTIAL_WAKE_LOCK` (CPU on, screen off) is usually what you need. `FULL_WAKE_LOCK` keeps the screen on and is rarely appropriate.
- Prefer WorkManager: Just use WorkManager. It manages wake locks for its own tasks automatically, which is much safer than you trying to juggle them manually.
Likewise, alarms scheduled with `AlarmManager` can wake the device up from a deep sleep. Use them sparingly. Methods like `setAndAllowWhileIdle()` are powerful for running tasks during Doze mode, but they’re a big drain on the battery. For non-exact, non-critical tasks, WorkManager is better. To get background execution right on Android, you need to know the system’s rules and use the right APIs. If you make WorkManager your default for deferrable jobs, use foreground services correctly, and profile your app constantly, you’ll build something that’s powerful without destroying your users’ batteries.
What is Android Doze mode?
It’s a power-saving state in Android 6.0+ that activates when a device is unplugged, stationary, and screen-off. Doze mode saves battery by restricting background CPU and network access, batching jobs and alarms together to run in specific maintenance windows instead of whenever they want.
Why did Android introduce background execution limits?
Starting with Android 8.0 (Oreo), the OS got much stricter to improve battery life and stop apps from secretly hogging resources. Before these limits, any app could run services in the background indefinitely, which led to terrible performance and battery drain across the board.
When should I use WorkManager versus a Foreground Service?
Use WorkManager for tasks that can be delayed and run opportunistically (like syncing data). Use a Foreground Service only for tasks the user needs to see happening right now, like music playback or a workout tracker, because it requires showing a persistent notification.
Can I use `AsyncTask` for background operations in 2026?
Don’t. `AsyncTask` was deprecated in API level 30 (Android 11) for good reason, it’s prone to memory leaks and doesn’t handle modern app lifecycles well. For any new code, you should be using Kotlin Coroutines for UI-related background work or WorkManager for more durable tasks.
How can I test my app’s battery usage effectively?
Test on a real, physical device, not just the emulator. Use ADB to simulate bad network conditions and low battery. The `adb shell dumpsys battery` command will give you a detailed breakdown of what’s using power. Also, pay close attention to the Android Vitals dashboard in your Google Play Console, as it gives you real-world battery data from your users.