Structured Coroutines in Kotlin: How the Wrong Scope Leaks Memory in Android Apps
Every time we look into a memory leak report in an Android app that already uses coroutines, the pattern repeats: someone launched a coroutine from GlobalScope, or from a custom scope that never gets cancelled, and that coroutine stays alive long after the view that started it is gone. The result is no different from the classic AsyncTask leak with an implicit reference to the Activity, except now it happens inside an API that promises to solve exactly that problem. Structured concurrency is not magic that prevents leaks on its own; it is a discipline that Kotlin makes easy to follow when you pick the right scope, and easy to break when you don't.
What structured concurrency actually solves
Before coroutines, the lifecycle of an asynchronous task on Android was managed by hand. An AsyncTask held a reference to the Activity so it could update the UI when it finished, and if the Activity was destroyed before the task completed, that reference stayed alive inside the background thread until the task finished, dragging the whole view and its object tree along with it. The usual fix was to manually null out the callback in onDestroy, which only worked as long as nobody forgot to write that line.
Structured concurrency flips the responsibility: instead of each individual task deciding when to cancel itself, every coroutine is born inside a CoroutineScope that has its own lifecycle, and cancelling that scope automatically cancels every coroutine launched inside it, including any child coroutines those in turn launched. The Job hierarchy is what makes this possible: every launch or async creates a child Job under the scope's Job, and cancelling a parent Job propagates down through its entire descent.
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
scope.launch {
val user = fetchUser() // child coroutine 1
launch {
syncAnalytics(user) // child coroutine 2, nested
}
}
scope.cancel() // cancels all three: the outer launch and both children
The API is not the problem. The problem is that this guarantee only holds if the scope you actually use gets cancelled at the right moment, and that is exactly where GlobalScope breaks the promise.
Why GlobalScope leaks memory
GlobalScope has no lifecycle. It is a CoroutineScope that lives as long as the application process, with no parent Job that anyone is going to cancel when a screen disappears. Launching a coroutine from GlobalScope inside a Fragment or an Activity effectively disconnects that coroutine from the lifecycle of the view that started it:
class ProfileFragment : Fragment() {
private lateinit var userRepo: UserRepository
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
GlobalScope.launch {
val user = userRepo.fetchUser() // slow network call
withContext(Dispatchers.Main) {
binding.username.text = user.name // binding may no longer exist
}
}
}
}
If the user navigates away from ProfileFragment before fetchUser() returns, the coroutine keeps running. When it eventually completes, it tries to write to binding.username, which at best has already been released by the Fragment's lifecycle and throws an exception, and at worst keeps the entire binding alive — along with its reference to the view and everything that view retains — because the lambda captured that reference and the coroutine holding it hasn't finished yet. That is the exact mechanism of the leak: it's not that GlobalScope itself takes up memory, it's that every coroutine launched from it retains, for as long as it lives, everything it captured in its closure.
The scopes that do have a lifecycle
Android Jetpack exposes two scopes built for exactly this case, and the difference between them comes down to which level of the lifecycle they're tied to.
lifecycleScope, available on any LifecycleOwner (Activity or Fragment), cancels its coroutines once the Lifecycle reaches DESTROYED. It fits work that only makes sense while that specific view exists:
class ProfileFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
val user = userRepo.fetchUser()
binding.username.text = user.name
}
}
}
Notice the use of viewLifecycleOwner rather than this as the LifecycleOwner in a Fragment: a Fragment's view can be destroyed and recreated while the Fragment itself stays alive (inside a ViewPager, for instance), and using this.lifecycleScope in that case leaves coroutines running against a binding that no longer exists, reproducing the same leak as GlobalScope but more subtly, because the scope does get cancelled — just at the wrong moment.
viewModelScope, available on any ViewModel, is cancelled when ViewModel.onCleared() fires — that is, when the parent Activity or Fragment is destroyed for good, not on every screen rotation. It's the right scope for work that needs to survive configuration changes but not the actual destruction of the screen:
class ProfileViewModel(private val userRepo: UserRepository) : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user
fun loadUser() {
viewModelScope.launch {
_user.value = userRepo.fetchUser()
}
}
}
A screen rotation doesn't cancel this coroutine, because the ViewModel survives the Activity being recreated; leaving the screen for good does cancel it, because that's when onCleared() fires. That distinction — survives rotation, doesn't survive real destruction — is why viewModelScope is almost always the right choice for work started from the presentation layer, leaving lifecycleScope for work that genuinely depends on a specific view being on screen, like animations or UI updates that make no sense to resume from inside a ViewModel.
What happens when a coroutine outlives the view that launched it
The most visible symptom of a badly chosen scope is not always a clean OutOfMemoryError. More often it's an intermittent NullPointerException on a null binding, a crash reported as rare because it only happens when the user navigates quickly, or memory usage that creeps up slowly over long sessions because every navigation leaves behind hung coroutines holding on to fragments of the previous view hierarchy. None of these symptoms obviously points to coroutines as the cause, which is why this kind of leak takes a while to diagnose: the exception's stack trace shows where it failed, not where the coroutine that should have been cancelled was launched.
Catching it during development is simpler than catching it in production. Android Studio's memory profiler lets you force a screen rotation or a back navigation and check whether the heap dump still retains instances of the previous Activity or Fragment; if it does, there is almost always a coroutine with the wrong scope holding that reference. LeakCanary detects the same pattern automatically, and its trace usually points straight at a reference chain running through a coroutine's Continuation, which is the most direct clue that the problem is a scope, not a forgotten listener.
What structured concurrency doesn't solve by itself
Choosing the right scope prevents the most common leak, but it doesn't solve everything. A misused SupervisorJob inside viewModelScope can hide exceptions: if a child coroutine throws an uncaught exception and the parent is a SupervisorJob, that exception doesn't cancel sibling coroutines — which is desirable, so one isolated failure doesn't take down the whole ViewModel — but if nobody installs a CoroutineExceptionHandler or wraps the call in a try/catch, the exception simply vanishes without a trace, and the symptom becomes a piece of data that never updated, with no crash to give it away.
It also doesn't fix leaks caused by custom scopes built by hand outside Jetpack's lifecycle — for example, an application-wide scope created with CoroutineScope(SupervisorJob() + Dispatchers.IO) inside a singleton class — where the responsibility for cancelling falls right back on whoever wrote that scope, the exact same point of human failure that existed with AsyncTask. What Kotlin actually changes is not that leaking memory with coroutines becomes impossible; it's that the API no longer forces you to implement cancellation from scratch. Delegating it to a scope Jetpack already tied to the right lifecycle is usually enough, and hand-rolled scopes should be reserved for the few cases where you genuinely need to control that lifecycle yourself.