Kotlin Coroutines are essential in modern Android development. They help developers write asynchronous code—such as fetching data from APIs, querying databases, or updating the UI—without blocking the main thread. But writing coroutines is only half the challenge. The real key to building stable and memory-safe Android apps is understanding which coroutine scope to use and when.
Android applications have complex and dynamic lifecycles:
- Activities and Fragments are constantly created and destroyed
- ViewModels survive configuration changes
- Background workers run even when UI disappears
Because of this, choosing the correct coroutine scope is critical to avoid:
- memory leaks
- crashes
- coroutines running after UI is gone
- flows collecting when UI is not visible
- unnecessary work
This article explores:
- Lifecycle-aware coroutines
viewModelScopelifecycleScoperepeatOnLifecycle- Avoiding leaks with structured concurrency
- Real example: safely fetching data from an API and updating the UI
Let’s start with the core idea.
Lifecycle-Aware Coroutines
Android components have lifecycles:
- Activities → onCreate → onStart → onResume → onPause → onStop → onDestroy
- Fragments → similar lifecycle but attached/detached from activity
If a coroutine continues working after its owner is gone, you risk:
- crashes (e.g., updating a destroyed view)
- wasted resources (network calls continue even though user left screen)
- memory leaks
To prevent this, Android provides lifecycle-aware scopes that automatically cancel coroutines when the host component is destroyed.
The key principle:
Coroutines should only run as long as their owners are alive.
This concept is a part of Kotlin’s structured concurrency: child tasks must follow the parent’s lifecycle.
Android offers two major lifecycle-aware scopes:
viewModelScopefor ViewModelslifecycleScopefor Activities/Fragments
Each has a different purpose, and choosing the right one is essential.
viewModelScope
The ViewModel’s job is:
- survive configuration changes (rotation, screen size changes)
- provide data to UI
- prepare business logic
- keep long-running tasks safe from Activity/Fragment destruction
Because ViewModels live longer, they need their own coroutine scope:
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
// long-running tasks
}
}
}
When does viewModelScope cancel?
When the ViewModel is cleared:
- user leaves screen
- Activity is destroyed permanently
- ViewModelStoreOwner removes ViewModel
When this happens:
- all running coroutines inside
viewModelScopeare automatically cancelled - network/database operations stop immediately
- resources get cleaned up
This ensures no more work happens after the screen disappears.
When to use viewModelScope
Use it for tasks that should survive brief UI destruction:
- fetching API data
- caching or saving data
- database queries
- updating UI state stored inside a StateFlow
- performing background computation
Never update UI directly from viewModelScope
Wrong:
activity.textView.text = "Hello" // BAD
Correct: expose state instead:
private val _state = MutableStateFlow("")
val state = _state.asStateFlow()
viewModelScope.launch {
_state.value = "Hello"
}
Then UI collects from the state.
lifecycleScope
lifecycleScope exists on:
- Activities
- Fragments
Example:
lifecycleScope.launch {
// runs as long as the Activity/Fragment is alive
}
Cancellation behavior:
lifecycleScopecancels coroutines when the lifecycle reachesonDestroy()
This means:
- good for UI-related logic
- bad for long tasks that should persist across configuration changes
- safe for tasks tied to visual components
When to use lifecycleScope
Use it when the work should stop once UI disappears:
- playing animations
- responding to button clicks
- collecting flows tied to the current visible UI
- observing ViewModel state
repeatOnLifecycle
One common bug in Android apps:
Collecting Flow in lifecycleScope causes emissions to continue even when the UI is stopped (e.g., screen off, app background).
This wastes battery and bandwidth.
To fix this, AndroidX introduced repeatOnLifecycle:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
render(state)
}
}
}
What does this do?
- Flow collection starts when lifecycle enters
STARTED - Flow collection stops when lifecycle moves to
STOPPED - Collectors are automatically restarted when the lifecycle resumes
- Cancellation is automatic and safe
Traditional lifecycleScope collection looked like:
lifecycleScope.launch {
viewModel.state.collect { ... }
}
But this continues running even when Activity is not visible.
repeatOnLifecycle solves this cleanly.
Avoiding Leaks with Structured Concurrency
Structured concurrency ensures:
Parent tasks automatically cancel child tasks.
This prevents “orphan coroutines” that leak memory or cause crashes.
How leaks happen without structure:
Example (DON’T DO THIS):
GlobalScope.launch {
delay(5000)
view.text = "Done" // activity may be destroyed
}
Problems:
- GlobalScope never cancels
- coroutine outlives UI
- crash: “Attempt to invoke virtual method on a null object reference”
Proper structure:
- Use lifecycleScope for UI
- Use viewModelScope for business logic
- Avoid creating ad-hoc CoroutineScopes inside Activities
Good pattern:
viewModelScope.launch {
_state.value = repository.load()
}
Then in UI:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { render(it) }
}
}
This ensures:
- Cancel when UI gone → lifecycleScope
- Keep long tasks alive → viewModelScope
- No leaks
- No crashes
- No unnecessary work
Real Example: Fetching API + Updating UI Safely
Let’s build a realistic system:
- ViewModel fetches data from API
- UI observes state
- UI updates only when visible
- Cancellation is automatic
- No crashes or leaks
Step 1: Define UI State
data class UserUiState(
val loading: Boolean = false,
val user: User? = null,
val error: String? = null
)
Step 2: ViewModel loads data
class UserViewModel(private val api: Api) : ViewModel() {
private val _state = MutableStateFlow(UserUiState())
val state = _state.asStateFlow()
fun loadUser(id: Int) {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true)
try {
val user = api.getUser(id) // suspend function
_state.value = UserUiState(
loading = false,
user = user
)
} catch (e: Exception) {
_state.value = UserUiState(
loading = false,
error = e.message
)
}
}
}
}
Why use viewModelScope?
- ViewModel outlives configuration changes
- API should continue even if Activity rotates
- No UI references here, so safe
Step 3: UI collects state with repeatOnLifecycle
Fragment example:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
renderUi(state)
}
}
}
viewModel.loadUser(42)
}
Behavior:
- When screen is visible → collect state
- When user presses home → stop collecting
- When user comes back → resume collecting
- No leaks
- No wasted CPU/network
This is now the recommended pattern on Android.
Step 4: Why this pattern is safe
1. Structured concurrency manages cancellation
- repeatOnLifecycle suspends and cancels child coroutines
- ViewModel cancels its own tasks when cleared
2. Prevents crashes
Even if API call returns late:
- viewModelScope updates StateFlow
- StateFlow safely updates ViewModel’s state
- UI only collects when visible
3. Prevents wasted work
If user leaves screen mid-fetch:
- repeatOnLifecycle cancels collection
- but viewModelScope keeps API call alive
- ensures data available when user returns
Combining viewModelScope + lifecycleScope
They serve different roles:
| Scope | Lives As Long As | Use For |
|---|---|---|
| viewModelScope | ViewModel | Business logic, API calls, DB queries |
| lifecycleScope | Activity/Fragment lifecycle | UI updates, Flow collection |
| repeatOnLifecycle | Running UI state only when visible | Clean, optimized UI logic |
Bonus: Common Mistakes to Avoid
❌ Using GlobalScope
Instead:
viewModelScope.launch { ... }
or
lifecycleScope.launch { ... }
❌ Launching coroutines directly in Adapter/View classes
Instead, pass events upward to ViewModel.
❌ Using lifecycleScope to do long background operations
These will be cancelled on every small UI change.
Use viewModelScope instead.
❌ Collecting flows without repeatOnLifecycle
This causes memory leaks and wasted work.
Conclusion
Understanding coroutine scopes is essential for building safe, reactive, and efficient Android apps. Using the correct scope ensures your coroutines are lifecycle-aware, automatically cancelled, and protected from memory leaks or crashes.
You learned:
- Why lifecycle-aware coroutines matter
- How
viewModelScopehandles long-running tasks - How
lifecycleScopemanages UI-bound tasks - How
repeatOnLifecycleprevents leaks - How structured concurrency ensures cleanup
- A real, safe pattern for fetching API data and updating UI
Key takeaways
- Use viewModelScope for data & logic
- Use lifecycleScope for UI collection
- Use repeatOnLifecycle for visibility-aware UI
- Never use GlobalScope in Android
- Leverage structured concurrency to avoid leaks
