Skip to content
All posts
May 15, 20264 min read

Android Background Processing: WorkManager vs Coroutines vs Services

Android has multiple ways to run background work. Choosing wrong leads to work that gets killed by Doze mode, battery drain, or ANRs. Here's when to use WorkManager, coroutines, foreground services, and how each interacts with the system.

AndroidCoroutinesKotlin
Share:

Android's background processing rules have tightened significantly across OS versions. Work that "just ran in a thread" on Android 5 gets killed by Doze mode on Android 10+. The right tool depends on the nature of the work.


The Decision Framework

code
Is the work user-initiated and currently visible to the user?
├── Yes → Coroutines in ViewModel (tied to UI lifecycle)
└── No

Does the work need guaranteed completion (must finish even if app is killed)?
├── Yes → WorkManager
└── No

Is the work long-running and user-visible (like music playback, file upload)?
├── Yes → ForegroundService
└── No → Coroutines in a non-UI scope

Coroutines for UI-Tied Work

Most work that starts from user action and completes while the screen is visible:

kotlin
class TaskViewModel : ViewModel() {
    
    fun loadTasks() {
        viewModelScope.launch {
            // Automatically cancelled when ViewModel is destroyed
            val tasks = repository.getAllTasks()
            _uiState.value = TaskUiState.Success(tasks)
        }
    }
    
    fun syncTasks() {
        viewModelScope.launch {
            _uiState.value = TaskUiState.Loading
            try {
                taskSyncService.sync()
                _uiState.value = TaskUiState.Success(repository.getAllTasks())
            } catch (e: Exception) {
                _uiState.value = TaskUiState.Error(e.message ?: "Sync failed")
            }
        }
    }
}

code
viewModelScope
is tied to the ViewModel lifecycle. When the ViewModel is cleared (user navigates away), all coroutines cancel. This is correct behavior for UI-associated work.

For work that should outlive the ViewModel but not need guaranteed completion, use

code
applicationScope
(inject from Application):

kotlin
class MyApplication : Application() {
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}

WorkManager for Guaranteed Work

WorkManager runs work even if the app is closed or the device restarts. Use it for:

  • Uploading data to a server
  • Processing files in the background
  • Sending analytics events
  • Periodic data syncs
kotlin
class UploadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    
    override suspend fun doWork(): Result {
        val fileUri = inputData.getString("file_uri") ?: return Result.failure()
        
        return try {
            uploadService.upload(fileUri)
            Result.success()
        } catch (e: Exception) {
            if (runAttemptCount < 3) Result.retry()
            else Result.failure(workDataOf("error" to e.message))
        }
    }
}

// Schedule
val uploadRequest = OneTimeWorkRequestBuilder<UploadWorker>()
    .setInputData(workDataOf("file_uri" to fileUri.toString()))
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
    .build()

WorkManager.getInstance(context)
    .enqueueUniqueWork("file_upload_$fileId", ExistingWorkPolicy.KEEP, uploadRequest)

code
enqueueUniqueWork
prevents duplicate work — if the same file is already queued for upload, it won't add another.


ForegroundService for Long-Running Visible Work

Work that's user-visible (music player, file download, GPS tracking) needs a

code
ForegroundService
to avoid being killed:

kotlin
class MusicPlaybackService : Service() {
    
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val notification = buildPlaybackNotification()
        startForeground(NOTIFICATION_ID, notification)
        
        // Start the actual work
        mediaPlayer.start()
        
        return START_STICKY // Restart if killed
    }
    
    private fun buildPlaybackNotification(): Notification {
        return NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_music)
            .setContentTitle("Playing: ${currentTrack.title}")
            .addAction(R.drawable.ic_pause, "Pause", pausePendingIntent)
            .build()
    }
}

The notification shows users the work is running. Without a visible notification, the foreground service won't survive.


Doze Mode and App Standby

Android's battery optimization restricts background work when the device is idle:

Doze Mode (device stationary and unplugged):

  • Network access suspended
  • AlarmManager deferred
  • Jobs and syncs deferred
  • Partial wake locks degrade

App Standby:

  • Apps not recently used get restricted access to network and jobs

WorkManager handles both — it uses JobScheduler internally, which respects Doze while ensuring work runs when the device wakes up.

What still runs in Doze:

  • FCM high-priority messages (push notifications)
  • Foreground services
  • WorkManager tasks with
    code
    setExpedited()

For urgent background work:

kotlin
val urgentWork = OneTimeWorkRequestBuilder<SyncWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

Common Mistakes

Running long tasks in

code
onReceive()
of BroadcastReceiver. The receiver is destroyed quickly. Use
code
goAsync()
or schedule a WorkManager job instead.

Using

code
Thread.sleep()
in a Worker. WorkManager workers are coroutine-capable — use
code
delay()
instead, which is cancellation-aware.

Scheduling too much periodic work. Every periodic WorkManager task runs regardless of whether there's anything to do. Use interval wisely — most apps don't need more frequent than 15 minutes.

Not handling

code
Result.failure()
in observers. WorkManager work can fail permanently. Handle it:

kotlin
WorkManager.getInstance(context)
    .getWorkInfoByIdLiveData(uploadRequest.id)
    .observe(this) { workInfo ->
        when (workInfo?.state) {
            WorkInfo.State.SUCCEEDED -> showSuccess()
            WorkInfo.State.FAILED -> showError("Upload failed")
            WorkInfo.State.RUNNING -> showProgress()
            else -> Unit
        }
    }

Quick Reference

Use CaseTool
User-triggered, completes with screen
code
viewModelScope
coroutine
Must complete even if app closes
code
WorkManager
Long-running, user-visible
code
ForegroundService
Periodic background sync
code
WorkManager.enqueueUniquePeriodicWork
Reacts to system events (boot, connectivity change)
code
BroadcastReceiver
code
WorkManager

Takeaways

  • code
    viewModelScope
    coroutines are for UI-tied work — they cancel with the ViewModel
  • WorkManager guarantees work completion across app kills and device restarts
  • ForegroundService with a persistent notification is required for long-running user-visible work
  • Doze Mode will defer background work — WorkManager handles this correctly
  • Never do heavy work in
    code
    BroadcastReceiver.onReceive()
    — it's short-lived
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this