Coroutine scopes on Android: what developers should know
Coroutines have transformed how we handle asynchronous work on Android, but misusing scopes can lead to memory leaks, cancelled operations, and unpredictable crashes. The scope you choose determines when your coroutine runs, where it can be cancelled, and how its lifecycle ties to your app's components. Getting this right is the difference between a robust app and one that leaks resources or loses user data mid-operation.
Understanding coroutine scopes
A coroutine scope is a context that determines the lifetime and cancellation behavior of coroutines. Think of it as a container that knows when to clean up—when a ViewModel is destroyed, or when a screen leaves the foreground. Android provides a few well-defined scopes, each suited to different layers of your app.
Every scope is tied to a Job that tracks all coroutines launched within it. When the scope cancels—because a component is destroyed, or because you explicitly call cancel()—all child coroutines are cancelled too. This cascading cancellation is what prevents memory leaks and orphaned work.
viewModelScope for ViewModel work
The viewModelScope is your go-to for any coroutine work that outlives a single screen interaction. It's tied to the ViewModel's lifecycle, which typically spans configuration changes and persists until the ViewModel is truly destroyed.
Use viewModelScope when you're:
- Fetching data from a network or database
- Performing business logic that doesn't depend on immediate UI events
- Working with state that survives screen rotations
Here's a typical pattern from a user profile screen:
class ProfileViewModel(private val userRepository: UserRepository) : ViewModel() {
private val _userState = MutableStateFlow<UiState>(UiState.Loading)
val userState: StateFlow<UiState> = _userState.asStateFlow()
fun loadUser(userId: String) {
viewModelScope.launch {
try {
_userState.value = UiState.Loading
val user = userRepository.fetchUser(userId)
_userState.value = UiState.Success(user)
} catch (e: Exception) {
_userState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}
sealed class UiState {
object Loading : UiState()
data class Success(val user: User) : UiState()
data class Error(val message: String) : UiState()
}When the ViewModel is destroyed (usually when the user navigates away permanently), viewModelScope automatically cancels all pending coroutines. This means your network request won't complete and try to update a destroyed state, and your resources are freed.
The scope also survives configuration changes. If the user rotates the device mid-load, the same ViewModel persists, so the network request keeps running in the same scope. The UI just recomposes or rebinds to the same state.
lifecycleScope for UI-bound work
The lifecycleScope is tied to a lifecycle owner—typically a Fragment or Activity. It's best for work that should pause when the screen is backgrounded and resume when it returns to the foreground.
Use lifecycleScope when you're:
- Observing UI events that are only relevant when the screen is visible
- Launching animations or transitions
- Updating the UI in response to rapid user interactions
- Running work that should pause when the screen is not visible
The lifecycleScope also provides lifecycle-aware builders like launchWhenStarted and repeatOnLifecycle, which respect the Lifecycle state:
class ProfileFragment : Fragment() {
private val viewModel: ProfileViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// This will suspend collection when the fragment is STOPPED,
// and resume when it's STARTED again
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.userState.collect { uiState ->
when (uiState) {
is UiState.Loading -> showLoadingSpinner()
is UiState.Success -> displayUser(uiState.user)
is UiState.Error -> showError(uiState.message)
}
}
}
}
}
}Notice we're using repeatOnLifecycle rather than launching directly. This suspends the collection when the fragment stops, preventing unnecessary work when the screen isn't visible, and resumes automatically when it starts again. It's more battery-efficient and safer than launching without lifecycle awareness.
The older launchWhenStarted is still valid but doesn't cancel the underlying coroutine—it just delays collection. For most modern apps, repeatOnLifecycle is the better choice.
Never use GlobalScope
GlobalScope launches a coroutine with no lifecycle awareness. It runs on a global Job that is never automatically cancelled. This is almost always wrong.
Consider this anti-pattern:
// DON'T DO THIS
GlobalScope.launch {
val user = userRepository.fetchUser(userId)
updateUI(user) // May crash if the UI is destroyed
}Why this fails:
- The coroutine keeps running even after the Activity or Fragment is destroyed
- If you try to update the UI, you'll crash with a NullPointerException or IllegalStateException
- You can't easily cancel the work if the user navigates away
- It's a classic memory leak: the coroutine holds references to destroyed components
The Kotlin team even marked GlobalScope with @DelicateCoroutinesApi to discourage its use. If you ever feel tempted to use it, ask yourself: "Does this work belong to a ViewModel, an Activity, or a Fragment?" The answer almost always points to viewModelScope or lifecycleScope.
The only legitimate use case is in top-level app initialization code that runs once at startup, but even then, structured concurrency is usually a better design.
Cancellation happens automatically
One of the biggest wins of using the right scope is automatic cancellation. When a scope is cancelled, all coroutines within it are cancelled, and resources are freed.
Here's what happens in practice:
class SearchViewModel : ViewModel() {
private val _results = MutableStateFlow<List<Item>>(emptyList())
val results: StateFlow<List<Item>> = _results.asStateFlow()
fun search(query: String) {
viewModelScope.launch {
delay(500) // Debounce
try {
val items = apiClient.search(query)
_results.value = items
} catch (e: CancellationException) {
// This is thrown when viewModelScope cancels
throw e // Always re-throw CancellationException
} catch (e: Exception) {
// Handle other errors
_results.value = emptyList()
}
}
}
// ViewModel destroyed -> viewModelScope.cancel() called automatically
// All pending search coroutines are cancelled
}If the user starts a search, then immediately navigates away and the ViewModel is destroyed, the coroutine is cancelled. The API request might still be in flight, but once it completes, the result won't update any UI because the scope is dead.
The cancellation is cooperative—coroutines that call suspending functions (like delay, withContext, or any suspend function) will check for cancellation and throw CancellationException. Blocking code that never suspends won't be cancelled immediately, but that's another reason to keep heavy work off the main thread.
Common mistakes
Mistake 1: Launching from the wrong scope
Don't launch UI updates from viewModelScope expecting them to happen at exactly the right lifecycle moment. That's what lifecycleScope is for. viewModelScope can outlive the UI, so updates might fail.
Mistake 2: Ignoring CancellationException
If you catch all exceptions with a broad catch block, you might accidentally swallow CancellationException. Always re-throw it:
viewModelScope.launch {
try {
// work
} catch (e: CancellationException) {
throw e // Always re-throw
} catch (e: Exception) {
// Handle actual errors
}
}Mistake 3: Creating custom scopes without clear ownership
If you create a scope with CoroutineScope(Dispatchers.Main), you own the responsibility to cancel it. In nearly all cases, stick to viewModelScope or lifecycleScope.
Mistake 4: Mixing lifecycleScope with blocking operations
If you launch a long-running blocking operation in lifecycleScope, it will hold resources even when the UI is paused. Use Dispatchers.Default and move the work off the main thread:
lifecycleScope.launch(Dispatchers.Default) {
val result = heavyComputation()
withContext(Dispatchers.Main) {
updateUI(result)
}
}Key takeaways
- Use
viewModelScopefor business logic, data fetching, and state management that survives configuration changes. - Use
lifecycleScopewithrepeatOnLifecyclefor UI updates and work that should pause when the screen is backgrounded. - Never use
GlobalScope—it has no lifecycle awareness and causes memory leaks. - Cancellation happens automatically when a scope is destroyed; you don't need to manage it manually.
- Always re-throw
CancellationExceptionif you catch exceptions in a coroutine. - Choose your scope based on the lifetime of the work, not convenience.






Comments
Loading comments…