The Background Task Dilemma
When I was building Nova Cabs, we faced a critical challenge: the app needed to track driver location in real-time, sync ride data, and process payments—all without draining the battery or getting killed by the system. This is the core problem every Android developer faces when building production apps.
Android development has evolved dramatically over the past 8 years. What worked in Android 5 would get your app terminated in Android 12+. The operating system became increasingly aggressive about killing background processes, and Google's APIs shifted from permissive (do whatever you want) to prescriptive (here's the approved way).
Today, you have three main patterns for handling Android background tasks:
- WorkManager — The recommended solution for most use cases
- Foreground Services — When users need to see ongoing activity
- Kotlin Coroutines — For lightweight, in-process async work
Getting this wrong is costly. I've seen apps crash on Android 12, lose user data, fail battery tests, and get rejected from the Play Store. I've also optimized apps that reduced crash rates by 35% through better background task management.
WorkManager: The Modern Approach
WorkManager is Google's official recommendation for background task scheduling. It's part of the Jetpack ecosystem and handles the complexity of different Android versions, device states, and system constraints for you.
Why WorkManager Won
When I migrated the AudioBook AI app (50K+ users) to use WorkManager instead of custom background schedulers, we saw immediate improvements:
- Tasks survived app crashes and device reboots
- The system optimized scheduling based on device state and battery
- No Play Store rejection issues
- Battery consumption dropped by ~12%
WorkManager handles the tedious details: it chooses between JobScheduler (Android 5.1+), GcmNetworkManager (older devices), and AlarmManager based on your API level. You just write the task logic.
When to Use WorkManager
Use WorkManager for:
- Guaranteed execution — Syncing data, uploading files, processing batches
- Delayed tasks — "Remind me in 2 hours", cleanup jobs
- Periodic work — Daily syncs, health checks, analytics
- Chained tasks — Task A completes, then Task B runs
- Tasks that survive app restart — The system will reschedule them
Foreground Services: When You Need Visibility
Foreground Services are different. They run in the foreground with a visible notification. The system won't kill them because the user explicitly sees they're running.
The Key Differences
WorkManager is best-effort. The system decides when to run your task based on battery, connectivity, and device state. Foreground Services are guaranteed to run immediately.
This matters for:
- Location tracking — Apps like maps, rideshare, fitness need real-time updates
- Active downloads/uploads — Users expect progress visibility
- Music playback — Media apps need continuous execution
- VoIP calls — Phone apps can't have gaps
The Catch
Starting with Android 12, you must declare the foreground service type in your manifest. And users can now disable them. A foreground service also burns battery faster because it's always running.
⚠️ Don't Abuse Foreground Services
I've seen developers use foreground services to bypass system restrictions and run background tasks indefinitely. Google will reject your app. Use them only when the user can actually see the ongoing work.
Kotlin Coroutines & Async Patterns
Neither WorkManager nor Foreground Services are appropriate for short-lived operations. This is where Kotlin Coroutines shine.
Coroutines are lightweight, non-blocking, and perfect for API calls, database queries, and image processing—as long as the app stays open or the work completes quickly.
In Jetpack Compose and modern Android development, coroutines integrated with lifecycles are the foundation. You launch them on appropriate scopes:
viewModelScope— Auto-cancelled when the ViewModel is clearedlifecycleScope— Tied to Fragment/Activity lifecyclelaunchIn(Dispatchers.IO)— For long-running operations
Coroutines don't survive app termination. If the user closes the app, your coroutine stops. For work that must complete, use WorkManager.
Choosing the Right Tool for Your Use Case
Here's my decision tree from years of production experience:
Is the work short-lived (<10 seconds) and can be abandoned if the app closes?
- Yes → Use Kotlin Coroutines with lifecycleScope
- No → Continue
Does the user need to see the task running (download, upload, location tracking)?
- Yes → Use Foreground Service
- No → Continue
Must the task complete even if the app restarts or device reboots?
- Yes → Use WorkManager with appropriate constraints
- No → Use Kotlin Coroutines (with lifecycle awareness)
Real-World Implementation Example
Let me show you a practical WorkManager setup from the AudioBook AI app. We needed to sync reading progress to the cloud every hour, but only on WiFi to save mobile data:
// Define the Worker
class SyncReadingProgressWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
val bookId = inputData.getString("book_id") ?: return Result.retry()
val repository = AudioBookRepository()
repository.syncProgress(bookId)
Result.success()
} catch (e: Exception) {
if (e is IOException) {
Result.retry() // Retry on network errors
} else {
Result.failure() // Don't retry on app logic errors
}
}
}
}
// Schedule the periodic work
fun scheduleReadingProgressSync(context: Context) {
val syncRequest = PeriodicWorkRequestBuilder<SyncReadingProgressWorker>(
1, TimeUnit.HOURS,
15, TimeUnit.MINUTES // Flex interval
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
)
.addTag("reading_sync")
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"reading_sync",
ExistingPeriodicWorkPolicy.KEEP,
syncRequest
)
}
// Cancel work when user logs out
fun cancelReadingSync(context: Context) {
WorkManager.getInstance(context).cancelAllWorkByTag("reading_sync")
}This approach ensures:
- Syncing happens at least once per hour, but with a 15-minute flex window (the system batches it with other tasks)
- It only runs on WiFi, preserving cellular data
- Battery-low condition halts syncing
- Network errors trigger automatic retries
- App logic errors fail gracefully without infinite retries
📖 Architecture Note
In Clean Architecture and MVVM Android patterns, WorkManager workers act as entry points into your use cases. They should be thin—delegate actual logic to repositories and interactors, never put business logic directly in the worker.
Now, here's a Foreground Service example for location tracking (like Nova Cabs):
class LocationTrackingService : Service() {
private val locationManager by lazy { getSystemService(Context.LOCATION_SERVICE) as LocationManager }
private val scope = CoroutineScope(Dispatchers.Default + Job())
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildNotification()
startForeground(LOCATION_NOTIFICATION_ID, notification)
scope.launch {
while (isActive) {
try {
val location = getLastKnownLocation()
uploadLocation(location)
delay(10000) // Update every 10 seconds
} catch (e: Exception) {
Log.e("LocationService", "Error tracking location", e)
}
}
}
return START_STICKY // Restart if system kills the service
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun buildNotification(): Notification {
return NotificationCompat.Builder(this, "location_channel")
.setContentTitle("Tracking Your Location")
.setContentText("Your ride is being tracked")
.setSmallIcon(R.drawable.ic_location)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
}
private suspend fun uploadLocation(location: Location) {
// API call to backend
}
private fun getLastKnownLocation(): Location {
// Location retrieval logic
}
}
// Start it from an Activity or ViewModel
fun startLocationTracking(context: Context) {
val intent = Intent(context, LocationTrackingService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}For this to work, you need manifest permissions and a notification channel:
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<service
android:name=".LocationTrackingService"
android:foregroundServiceType="location"
android:exported="false" />Key Takeaways
- WorkManager is your default — Use it for all guaranteed background work that doesn't need immediate execution. It's resilient, battery-efficient, and respects system constraints.
- Foreground Services are for visible work — Only use them when users see an active notification and understand the task is running. They drain battery faster and have stricter OS permissions.
- Kotlin Coroutines for short-lived async — They're lightweight, non-blocking, and perfect for API calls and database queries within the app lifecycle. They don't survive app termination.
- Respect Android's evolution — Doze mode, Battery Saver, scoped storage, and runtime permissions exist for good reasons. Building around them, not against them, leads to apps users actually want to keep installed.
- Test on real devices and OS versions — Emulators lie. Background task behavior varies wildly across Android 8, 10, 12, and 14. I always test on at least 3 real devices at different OS levels.