Async operations in Android are a constant headache, especially when they have to care about the component lifecycle. Kotlin Coroutines are a huge step up for background tasks, giving you a cleaner, safer structure than you’d get with old-school callbacks or even RxJava. But just throwing `launch` everywhere isn’t enough. If you don’t tie them to the Android lifecycle correctly, you’re just asking for memory leaks, crashes, and wasted battery. Here’s how to do it right so your background tasks are efficient and safe.
Key Takeaways
- Use
lifecycleScopeorviewModelScopefor any coroutine tied to a component’s lifecycle. - Use
launchWhenStartedor, better,repeatOnLifecyclefor fine-grained control over when a coroutine runs based on lifecycle state. - Inject a custom
CoroutineDispatcherduring tests for reliable and predictable results. - Keep long-running operations in a repository layer to separate your business logic from the UI.
- When the default scope cancellation isn’t enough, cancel coroutine jobs explicitly for custom lifecycle needs.
1. Incorporate Lifecycle-Aware Coroutine Scopes
Good coroutine management in Android boils down to using lifecycle-aware scopes. The Android Jetpack libraries give us `CoroutineScope` implementations that are smart enough to automatically cancel jobs when their component (like an Activity) gets destroyed. This alone stops a whole class of bugs, like the classic crash from trying to update a UI that’s already gone off-screen.
First, make sure the project has the right dependencies. In your module-level build.gradle.kts file, add these:
dependencies { implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1")
}
These versions are stable and well-supported. After a Gradle sync, `lifecycleScope` is available in Activities and Fragments, and `viewModelScope` is available in `ViewModel` classes.
Example for an Activity:
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // This coroutine automatically cancels when the Activity is destroyed. lifecycleScope.launch { Log.d("MainActivity", "Coroutine started in onCreate") delay(5000) // Simulate a long-running operation Log.d("MainActivity", "Coroutine finished after 5 seconds") // You'll never see this log if the Activity is destroyed before 5 seconds pass. } }
}
Example for a ViewModel:
class MyViewModel : ViewModel() { init { // This coroutine automatically cancels when the ViewModel is cleared. viewModelScope.launch { Log.d("MyViewModel", "Coroutine started in init") delay(10000) // Simulate a network request Log.d("MyViewModel", "Network request finished") } }
}
Just using these scopes makes your app much more stable. Both `lifecycleScope` and `viewModelScope` are pre-configured to launch on `Dispatchers.Main` by default, which is perfect for kicking off tasks from the UI thread.
Pro Tip: Stick to `viewModelScope` for any data fetching or logic that has to survive a configuration change. When an `Activity` or `Fragment` gets torn down and rebuilt on rotation, the `ViewModel` survives, letting your coroutine finish its work and update the *new* UI instance without missing a beat.
2. Use launchWhenStarted and repeatOnLifecycle for State-Aware Execution
So, lifecycleScope.launch is great for killing a job when a component is destroyed, but what if you need more control? Sometimes a coroutine should just pause when the app is backgrounded, not die completely. That’s what launchWhenStarted, launchWhenResumed, and the much better repeatOnLifecycle are for.
Before Lifecycle 2.4.0, people used `launchWhenStarted` a lot. It works by suspending the coroutine when the lifecycle state isn’t met (like pausing when the app goes into the `STOPPED` state) and resuming it later. The problem is that it keeps resources locked up even while suspended. There’s a much better way now.
The official recommendation since Lifecycle 2.4.0 is repeatOnLifecycle. This function is smarter: it suspends its own calling coroutine, waits for the lifecycle to hit the target state, and only *then* runs the block of code inside. The moment the lifecycle leaves that state, the block is cancelled. If it ever returns, a brand new coroutine is launched for the block. This ensures resources are only consumed when needed.
Example using repeatOnLifecycle in a Fragment:
class MyFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { // This block is tied to the view's lifecycle. viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { // This will run when the Fragment is STARTED and cancel when it's STOPPED. // If the user comes back, a new coroutine starts. Log.d("MyFragment", "Collecting data when STARTED") // Perfect for collecting a Flow from a ViewModel myViewModel.dataFlow.collect { data -> // Update UI with data Log.d("MyFragment", "Received data: $data") } } } }
}
Notice the use of `viewLifecycleOwner.lifecycleScope`. In a Fragment, this is non-negotiable. It ties the coroutine to the Fragment’s *view*, which has a shorter lifecycle than the Fragment instance itself. This is how you avoid trying to update a view that’s been destroyed while the Fragment object is still kicking around in memory.
Common Mistake: Directly using `lifecycleScope.launch` in `onViewCreated` to collect a flow and update the UI. If the view gets destroyed (like when the user navigates away and comes back), the Fragment instance might still be alive, and the old coroutine could try to touch the dead view, causing a crash. Always use `viewLifecycleOwner.lifecycleScope` with `repeatOnLifecycle(Lifecycle.State.STARTED)` for anything that touches a Fragment’s views.
3. Manage Background Work with Dispatchers
Dispatchers are how coroutines make thread management almost trivial. Everyone knows UI updates have to run on the main thread (`Dispatchers.Main`), but network calls or heavy processing will cause ANRs (Application Not Responding) if you don’t move them off it. Coroutines give you Dispatchers.IO for blocking stuff like network and disk access, and Dispatchers.Default for CPU-heavy work.
You can switch threads inside a coroutine with `withContext`:
class MyRepository { suspend fun fetchData(): String { // Switch to the I/O thread pool for this network call return withContext(Dispatchers.IO) { delay(2000) "Data from network" } } suspend fun processData(data: String): String { // Switch to the Default thread pool for this heavy computation return withContext(Dispatchers.Default) { delay(1500) data.uppercase() } }
} class MyViewModel : ViewModel() { private val repository = MyRepository() fun loadAndProcessData() { viewModelScope.launch { // Starts on the main thread val rawData = repository.fetchData() // suspends, runs on IO, then resumes on main val processedData = repository.processData(rawData) // suspends, runs on Default, resumes on main // Back on the main thread, safe to update UI state _uiState.value = processedData } }
}
This pattern keeps your code clean and guarantees long-running work stays off the main thread. Since the `viewModelScope.launch` block resumes on `Dispatchers.Main`, you can safely update `LiveData` or a `StateFlow` right after the suspend functions complete.
Pro Tip: For unit tests, you absolutely must inject a test dispatcher. Don’t let your tests depend on the real `Dispatchers.Main`, `.IO`, or `.Default`. It makes them slow and flaky. Instead, you can control time and execution order, making tests instant and reliable. The `kotlinx-coroutines-test` library gives you a `TestDispatcher` for exactly this. A common pattern is to define a `DispatchersProvider` interface that you can swap out with a test implementation.
4. Handle Exceptions Gracefully
An unhandled exception in a coroutine will crash your app. Period. You have to build in error handling. The two main ways are simple try-catch blocks and the more advanced CoroutineExceptionHandler.
For a single operation, a `try-catch` is usually the cleanest way to go:
viewModelScope.launch { try { val result = repository.fetchRiskyData() _uiState.value = result } catch (e: Exception) { Log.e("MyViewModel", "Error fetching data: ${e.message}") _uiState.value = "Error: ${e.message}" }
}
If you want a global handler for logging or showing a generic error message, a `CoroutineExceptionHandler` attached to your scope’s context is a better fit. It’s great for catching any exception that bubbles up from child coroutines.
val handler = CoroutineExceptionHandler { _, exception -> Log.e("CoroutineException", "Caught an exception: $exception") // Maybe show a generic error Toast here
} viewModelScope.launch(handler) { // Any unhandled crash in here or its children gets caught by our handler. val result = repository.fetchDataThatMightFail() _uiState.value = result
}
You also need to understand structured concurrency. When a child coroutine fails with an exception, it tells its parent. The parent then immediately cancels all its other children and then propagates the exception up the chain. This cascading failure is a good thing. It automatically prevents your app from ending up in a weird, partially updated state where one network call succeeded but another failed. It’s a powerful feature for simplifying error states, but it means you have to think about which scope should be responsible for catching the final error.
5. Consider Custom Coroutine Scopes for Non-Lifecycle Bound Work
Most of the time, `lifecycleScope` and `viewModelScope` are all you need. But sometimes you have work that isn’t tied to any single screen, like an application-wide background sync. For that, you might want a custom `CoroutineScope`.
When you make a custom scope, cancelling it becomes your job. A typical setup is to create a scope in your `Application` class with a `SupervisorJob()` and a default dispatcher:
class MyApplication : Application() { // This scope lives as long as the application process. val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onTerminate() { super.onTerminate() applicationScope.cancel() // You have to remember to do this! }
}
You can then inject or access this `applicationScope` anywhere in the app. It will only be cancelled when you explicitly call `cancel()` on it (or the whole app process dies). This is handy for things like pre-fetching data when the app starts.
Editorial Aside: I see way too many projects leaning on global or application-level scopes. Use them sparingly. If a task truly needs to survive even if the app process is killed and restarted (like uploading a large file), a custom coroutine scope is the wrong tool. That’s a job for WorkManager, which is built for guaranteed, persistent background work. Coroutines are for managing concurrency *inside* your app’s process.
And remember, a `SupervisorJob` is different from a regular `Job`. It lets its children fail without taking down the whole scope or its other children. A regular `Job` would cause one child’s failure to cancel all its siblings and the parent. Choose the one that matches the failure behavior you want.
Get these patterns right, and you can really lean on Kotlin Coroutines to build Android apps where your background tasks are efficient, tough, and lifecycle-aware. It makes for a much more stable app and a better user experience, because you’re actively preventing the crashes and memory leaks that come from async code running wild.
What’s the big deal with `lifecycleScope` vs. a plain `GlobalScope.launch`?
Automatic cancellation. Coroutines in `lifecycleScope` are automatically cancelled when their component (like an Activity) is destroyed. This prevents memory leaks and crashes from background tasks trying to update a dead UI. `GlobalScope`, on the other hand, is completely disconnected from any lifecycle, so its coroutines run until they finish or you manually cancel them, which is risky and easy to forget.
When should I use `viewModelScope` instead of `lifecycleScope`?
Use `viewModelScope` for any operation that should survive configuration changes, like a screen rotation. Because a `ViewModel` outlives the Activity or Fragment that’s being recreated, a coroutine in `viewModelScope` can continue running without interruption (e.g., finishing a network call) and then update the new UI instance. If you used `lifecycleScope`, the coroutine would be cancelled and restarted, which is wasteful.
What’s the difference between `Dispatchers.IO` and `Dispatchers.Default`?
Dispatchers.IO` is for I/O-bound work like network calls, reading/writing files, or database queries. It has a large thread pool because I/O tasks spend most of their time waiting, so it can handle many of them at once. Dispatchers.Default is for CPU-bound work like sorting a huge list, parsing JSON, or doing complex math. Its thread pool is sized to your device's CPU cores to maximize computational throughput.
How does `repeatOnLifecycle` actually manage resources better?
repeatOnLifecycle` saves resources by cancelling and restarting work instead of just pausing it. When the UI is not in the target state (e.g., it's `STOPPED`), the coroutine inside `repeatOnLifecycle` is completely cancelled, freeing up its memory and resources. Older methods like `launchWhenStarted` would only suspend the coroutine, which means it was still held in memory, waiting. `repeatOnLifecycle` ensures work is only happening (and consuming resources) when the UI is actually visible.
Can I use Coroutines for background tasks that need to run even if the app is closed?
No, coroutines are not the right tool for that. They only live as long as your app's process. For persistent background work that needs to run reliably even if the app is killed or the phone reboots, you must use Android's WorkManager API. WorkManager is designed for these scenarios and has great support for running its tasks using coroutines via the `CoroutineWorker` class.