What does structured concurrency actually give you in Kotlin coroutines, and how does a coroutine scope leak happen despite it?
Structured concurrency ties a coroutine's lifetime to a CoroutineScope so cancellation propagates automatically, but a leak still happens when a coroutine is launched in a scope that outlives the component that should own it, such as GlobalScope or a manually retained scope nobody cancels. Use this KOTLIN answer to show the decision, trade-off, and evidence rather than a memorised definition.
What the interviewer is scoring
- Can the candidate state what structured concurrency actually enforces, not just that it "manages coroutines"
- Do they explain cancellation propagation from parent to child rather than only describing scope creation
- Whether they can name a concrete leak pattern, such as GlobalScope or a scope with no cancellation hook, rather than a vague warning
- Can they distinguish viewModelScope's automatic cancellation from a manually created scope that needs explicit cleanup
- Does the candidate know why cancellation is cooperative and what happens if a coroutine ignores it
Answer
Short answer
Structured concurrency ties a coroutine's lifetime to a CoroutineScope so cancellation propagates automatically, but a leak still happens when a coroutine is launched in a scope that outlives the component that should own it, such as GlobalScope or a manually retained scope nobody cancels.
What structured concurrency enforces
Structured concurrency is the rule that every coroutine is launched inside a CoroutineScope, and that scope forms a parent-child relationship with the Job returned by that launch. The consequence that matters in practice is cancellation propagation: cancelling the scope cancels every coroutine launched inside it, including ones it launched that themselves launched further coroutines, all the way down the tree.
Before this model existed, a fire-and-forget coroutine had no natural owner, so nothing cancelled it when the component that started it went away, and it kept running - reading from a socket, writing to disk, updating UI state - against an object that no longer existed. Structured concurrency does not prevent you from creating that situation; it gives you a scope construct whose entire purpose is to prevent it, provided you attach coroutines to the right one.
The propagation also works the other way for exceptions in the default configuration: an unhandled failure in a child coroutine cancels its parent scope and every sibling coroutine within it, unless that scope was built with a SupervisorJob, which isolates a child's failure so siblings keep running. Knowing which of the two you are in changes whether one failed network call takes down every other coroutine started alongside it in the same scope.
Where the leak actually happens
The leak is never a failure of the mechanism - it is a coroutine launched in a scope whose lifetime does not match the lifetime of whatever it is doing work for. GlobalScope.launch is the textbook version: GlobalScope is tied to the application process, not to any screen or component, so a coroutine launched there runs until it finishes or the process dies, regardless of whether the Activity or ViewModel that started it is long gone.
class ProfileViewModel : ViewModel() {
fun loadProfile(userId: String) {
// Tied to the process, not this ViewModel. Rotating the screen or
// navigating away does nothing to stop this coroutine.
GlobalScope.launch {
val profile = repository.fetchProfile(userId)
_profileState.value = profile // may run after the ViewModel is cleared
}
}
}
viewModelScope exists specifically to close this gap: it is a CoroutineScope the ViewModel class provides that is automatically cancelled when onCleared() runs, which happens when the ViewModel is being destroyed for good. Launching in it means the coroutine's cancellation is wired to the exact lifetime you want with no manual bookkeeping.
class ProfileViewModel : ViewModel() {
fun loadProfile(userId: String) {
// Cancelled automatically when this ViewModel is cleared.
viewModelScope.launch {
val profile = repository.fetchProfile(userId)
_profileState.value = profile
}
}
}
The less obvious leak is a scope you create yourself and forget to cancel. A custom CoroutineScope held as a property on a long-lived object, created once and never explicitly cancelled in that object's teardown, behaves exactly like GlobalScope in practice even though it looks disciplined on the page - the discipline has to extend to actually calling cancel() somewhere, and that somewhere is easy to miss when the object has several exit paths.
Cancellation is cooperative, not preemptive
Calling cancel() on a scope does not forcibly stop a running coroutine mid-instruction. It sets the coroutine's job to a cancelling state, and the coroutine only actually stops the next time it reaches a suspension point that checks for cancellation - delay, most calls into kotlinx.coroutines machinery, or an explicit ensureActive() check. A coroutine that wraps a long blocking call with no suspension points inside it, or that catches CancellationException and swallows it instead of rethrowing, keeps running regardless of the cancellation having been requested. This is the detail that turns "I cancelled the scope" into a false sense of safety: the cancellation request happened, but nothing inside the coroutine ever noticed it.
viewModelScope.launch {
while (isActive) { // cooperative check; without it this never stops
val chunk = blockingRead()
process(chunk)
}
}
Where this shows up outside ViewModel
The same failure shape appears in Compose when a coroutine is launched directly from inside a @Composable function body rather than through LaunchedEffect. A @Composable function can re-execute many times during recomposition, and a bare coroutineScope.launch call inside it launches a new coroutine on every recomposition with no mechanism tying its lifetime to the composition it came from, producing exactly the same kind of orphaned, uncancelled work. LaunchedEffect exists to bind the coroutine's lifetime to the composable's presence in the tree, cancelling it automatically when the composable leaves.
The trap in this question is answering only "use viewModelScope" as though that were the whole answer. The general principle is that every coroutine needs an owner whose lifecycle you can name, and the specific scope you pick - viewModelScope, lifecycleScope, a LaunchedEffect-bound scope - is just the concrete expression of that owner for the component you are in. A candidate who can say why a given scope is the right one for a specific component's lifetime is answering the actual question; one who recites "always use viewModelScope" has memorised one instance of it.
A coroutine leak is never a failure of structured concurrency - it is a coroutine attached to a scope that outlives, or was never tied to, the thing it was actually doing work for.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- What actually happens inside a coroutine when its containing scope is cancelled mid-suspension?
- Why does launching a coroutine from a Composable directly, instead of through a LaunchedEffect, tend to cause leaks?
- How would you make a long-running coroutine cooperative with cancellation if it wraps a blocking call?
- What is the difference between SupervisorJob and a regular Job in how a child failure propagates?
Related questions
- How do you architect and implement Dynamic Feature Modules in an Android application to reduce initial download size and deliver features on demand?hardAlso on android and kotlin3 min
- How do you leverage WorkManager to guarantee execution, handle conflict resolution, and optimise battery usage in an offline-first Android application requiring bidirectional synchronisation?hardAlso on android and kotlin3 min
- How do you choose which devices to test on, and what will an emulator never tell you?mediumAlso on android5 min
- A Compose screen with a long list is janky while scrolling - walk through how you'd find and fix the recomposition problem causing it.mediumAlso on android5 min