Structured Concurrency in Depth

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:

  1. Why structured concurrency exists
  2. CoroutineScope rules
  3. Parent/child relationships in coroutines
  4. Cancelling children (and how they cancel parents)
  5. SupervisorJob & supervisorScope
  6. Mistakes that lead to coroutine leaks
  7. 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:

  • viewModelScope
  • lifecycleScope
  • coroutineScope { }
  • 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:

  • runBlocking is the top-level scope
  • parent is a child of runBlocking
  • 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
  • viewModelScope is 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:

  • userDeferred fails,
  • postsDeferred still 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.”

Leave a Reply

Your email address will not be published. Required fields are marked *