Structured concurrency is one of the most important—yet often misunderstood—concepts in Kotlin Coroutines. Many developers learn how to launch coroutines, switch dispatchers, or call suspend functions, but things only become truly safe, predictable, and leak-free when you understand structured concurrency.
Think of it this way:
Structured concurrency is the set of rules that keep your coroutines organized, scoped, and safe, preventing your app from spawning “rogue” background work that lives longer than it should.
In this article, you’ll learn:
- Why structured concurrency exists
- CoroutineScope rules
- Parent/child relationships in coroutines
- Cancelling children (and how they cancel parents)
- SupervisorJob & supervisorScope
- Mistakes that lead to coroutine leaks
- A real Android ViewModel example with cancellation
Let’s dive in.
Why Structured Concurrency Exists
Before structured concurrency, asynchronous programming was messy. Developers launched asynchronous jobs that had no relationship to where they were created.
This caused major problems:
1. Work continued after an object died
Imagine an Android Activity launching a background network call.
When the user leaves the screen, the Activity is destroyed—but the network call continues.
Result?
- callbacks run on a dead screen
- memory leaks
- crashes (“Called on a destroyed view”)
2. Hard-to-track child tasks
If you start 5 background tasks, where do you store references to them?
3. Cancelling became unpredictable
Without structure, cancellation required manual bookkeeping:
- store the reference
- cancel it manually
- ensure all children also stop
- handle errors globally
4. Exception handling was chaotic
If one async task crashes, should everything fail? Should only that task fail?
In typical callback-based or thread-based systems, this becomes complicated.
Structured concurrency solves all of these by giving 3 guarantees:
Guarantee #1: Every coroutine has a parent
There are no “freely-floating” tasks unless you explicitly choose so.
Guarantee #2: When a scope is cancelled, all its children are cancelled
This ensures deterministic cleanup.
Guarantee #3: A parent waits for its children before finishing
This avoids “unfinished work” bugs.
This makes programs predictable, leak-free, and easier to reason about.
CoroutineScope Rules
Every coroutine must run inside a CoroutineScope.
A scope defines:
- When coroutines start
- When they stop
- What dispatcher they run on
- How their errors propagate
Creating a scope manually
val scope = CoroutineScope(Dispatchers.Default + Job())
This scope now owns any coroutines launched inside it:
scope.launch {
doWork()
}
Rule 1: A scope must have a Job
The job manages:
- children
- cancellation
- lifecycle
Rule 2: Cancelling a scope cancels everything inside it
scope.cancel()
Every coroutine launched in this scope will:
- stop
- throw
CancellationException - clean up resources
Rule 3: Don’t create global scopes for request-scoped work
Common beginner mistake:
GlobalScope.launch { ... }
Global scope lives forever → leads to leaks.
Use structured scopes instead:
viewModelScopelifecycleScopecoroutineScope { }supervisorScope { }
Parent/Child Job Lifecycle
Understanding parent/child relationships is key.
Example:
runBlocking {
val parent = launch {
launch { delay(1000); println("Child 1 complete") }
launch { delay(2000); println("Child 2 complete") }
}
delay(500)
parent.cancel()
}
Result:
Child 1 cancelled
Child 2 cancelled
Why?
Because:
runBlockingis the top-level scopeparentis a child ofrunBlocking- Two coroutines (“Child 1” and “Child 2”) are children of
parent - Cancelling parent cancels all children
Parent waits for children
A parent coroutine cannot complete until its children do (unless cancelled).
Cancelling Children
A parent cancels children automatically.
But children can also cancel their parent depending on error handling rules.
Example: cancellation bubbling upward
runBlocking {
val parent = launch {
launch {
throw RuntimeException("Child failed")
}
launch {
delay(1000)
println("This never prints")
}
}
parent.join()
}
Result:
- child throws exception
- parent is cancelled
- siblings are cancelled
This is called cancellation propagation.
Why does this happen?
Because by default, failure in one child kills the parent.
This ensures consistent error handling, but sometimes you want different behavior.
This is where SupervisorJob enters the picture.
SupervisorJob, supervisorScope
Sometimes failure in one child should not cancel the parent or siblings.
Use case:
- parallel requests where one failing shouldn’t cancel the others
- UI children (like multiple small tasks in a ViewModel)
- retry strategies
- independent tasks
SupervisorJob
A SupervisorJob changes error propagation:
- children do NOT cancel the parent
- the parent does NOT cancel unaffected children
Example:
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
scope.launch {
throw RuntimeException("Fails")
}
scope.launch {
delay(1000)
println("Still running")
}
Explanation:
- First child fails
- SupervisorJob does NOT cancel the scope
- Second child continues unaffected
supervisorScope
A suspending block version of SupervisorJob:
supervisorScope {
launch {
throw RuntimeException("Fail")
}
launch {
delay(1000)
println("Still alive")
}
}
This is useful inside suspend functions.
Mistakes That Lead to “Coroutine Leaks”
Coroutines don’t “leak memory” in the traditional sense, but they can:
- continue running longer than they should
- run after the UI disappears
- execute when no one is listening
- keep database/network connections open
Major mistakes include:
1. Using GlobalScope
GlobalScope.launch {
// runs forever
}
Global scope ignores structured concurrency.
2. Creating ad-hoc scopes inside functions
fun loadData() {
CoroutineScope(Dispatchers.IO).launch { ... }
}
If the function returns and gets destroyed, the scope lives on.
3. Forgetting to cancel scopes
If you manually create a scope, you must cancel it.
4. Launching coroutines that outlive the owner
For example, launching coroutines inside:
- Android Activity
- Fragment
- custom View
- Presenter
But never cancelling them when UI disappears.
Leads to crashes:
Attempt to invoke virtual method on a null object reference
5. Mixing structured concurrency with ad-hoc async patterns
Launching async jobs without storing references causes tasks to float around.
Real-World Example: Handling Cancellation in Android ViewModels
Android ViewModels are the perfect place to use structured concurrency because:
- They outlive Activities/Fragments
- They are cleared automatically
- They need predictable cancellation
Android Jetpack provides:
viewModelScope.launch { ... }
This scope is tied to ViewModel lifecycle.
Example: Loading user + posts
class UserViewModel(
private val api: Api
) : ViewModel() {
val state = MutableLiveData<UiState>()
fun loadData() {
viewModelScope.launch {
state.value = UiState.Loading
try {
val user = api.getUser() // suspend
val posts = api.getPosts(user.id) // suspend
state.value = UiState.Success(user, posts)
} catch (e: Throwable) {
state.value = UiState.Error(e)
}
}
}
}
What structured concurrency guarantees here
- If the user leaves the screen, ViewModel is cleared
viewModelScopeis cancelled- All running network calls are cancelled
- No callback hits a destroyed fragment
- No leaks, no crashes
Handling parallel calls with supervisorScope
Sometimes you want:
- If user fails → show partial UI
- If posts fail → still show user
Example:
fun loadParallel() {
viewModelScope.launch {
state.value = UiState.Loading
supervisorScope {
val userDeferred = async { api.getUser() }
val postsDeferred = async { api.getPosts() }
val user = try { userDeferred.await() } catch (e: Exception) { null }
val posts = try { postsDeferred.await() } catch (e: Exception) { null }
state.value = UiState.Partial(user, posts)
}
}
}
Why supervisorScope?
Because even if:
userDeferredfails,postsDeferredstill continues
This would not be possible with regular structured concurrency.
Summary
Structured concurrency is one of the most important concepts in Kotlin Coroutines. It provides:
Safety
No rogue tasks that outlive their owners.
Predictability
Child tasks always follow their parent’s lifecycle.
Error Management
Failures propagate cleanly through the job hierarchy.
Cancellation Control
One line of code can cancel entire trees of tasks.
Real-World Stability
Especially in Android, structured concurrency prevents common UI crashes and leaks.
Final Takeaways
- Every coroutine belongs to a scope.
- Every scope has a Job controlling lifecycle.
- Cancelling a parent cancels all children.
- SupervisorJob & supervisorScope prevent unwanted cancellation.
- Avoid GlobalScope.
- In Android, rely on viewModelScope and lifecycleScope.
- Structured concurrency ensures reliability and prevents “coroutine leaks.”
