Bad Nesting in Kotlin Coroutines: The Bug That's not a Bug (Until it is)
Abizer

Abizer @abizer_r

Location:
Pune, India
Joined:
Aug 31, 2021

Bad Nesting in Kotlin Coroutines: The Bug That's not a Bug (Until it is)

Publish Date: Jul 30
7 3

⚠️ IMPORTANT UPDATE:
This article describes behavior from Kotlin coroutines versions prior to 0.27.0 (September 2018).
As pointed out by readers, withContext was changed in version 0.27.0 to "await for all launched tasks,"
effectively fixing the "bad nesting" issue described here.

For historical context and understanding older codebases, the information below remains accurate
for pre-0.27.0 versions. For modern development, this anti-pattern is no longer problematic

thanks to the structured concurrency improvements.

What is “Bad Nesting”?

We all love Kotlin coroutines because they’re clean, powerful, and great for writing async code that almost feels synchronous. But sometimes, coroutines betray us in the most subtle ways.

One such betrayal: bad nesting.

It happens when you put a coroutine builder like launch or async inside a withContext block, expecting it to behave like structured concurrency.

Spoiler alert: it doesn’t.


The Problem in a Nutshell

Bad nesting breaks structured concurrency and leads to:

  • Orphaned coroutines (they outlive the parent)
  • Logs that lie to you (“done” isn’t really done)
  • Concurrency bugs that make you question your sanity

Let’s See It in Action

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Before withContext")

    withContext(Dispatchers.IO) {
        launch {
            delay(1000)
            println("Inside launch")
        }
        println("withContext block done")
    }

    println("After withContext")
}
Enter fullscreen mode Exit fullscreen mode

Output:

Before withContext  
withContext block done  
After withContext  
Inside launch
Enter fullscreen mode Exit fullscreen mode

Wait... the launch runs after the outer scope thinks everything’s done?
Yes. And here’s why.


What's Really Happening?

Let’s break it down:

  1. runBlocking starts your main coroutine.
  2. withContext(IO) suspends and shifts work to an IO thread.
  3. Inside that block, you call launch. This creates a new coroutine, not tracked by withContext.
  4. withContext runs the block, hits the last line (println) and… finishes.
  5. The program resumes, even though launch is still running.

That launch is now a zombie coroutine, alive and unsupervised.
Remember: Job of withContext() is to switch dispatchers (threads) without starting a new coroutine. It doesn't track and wait for any coroutines launched inside it before returning.


Let Me Paint You a Picture

Imagine you're a team lead. You tell your assistant:

“Go to the warehouse and make sure all boxes are stacked.”

The assistant walks in, but instead of doing the stacking, he calls someone else and immediately walks out.

“Boxes are stacked, boss!”

Meanwhile, the boxes are still lying around, unstacked.

That’s exactly what happens when you launch inside withContext. The block returns, but the actual task isn’t finished.


Why Is This Dangerous?

  • You might start reading shared data before it’s been updated.
  • Cleanup might run before a job is complete.
  • Background tasks might leak or throw unexpected errors.

You think everything is done, but some coroutine is silently working in the background. That's a recipe for race conditions and flaky bugs.


How to Fix It

You’ve got two clean options depending on what you want:


1. Just do the work inside withContext

withContext(Dispatchers.IO) {
    delay(1000)
    println("Done properly")
}
Enter fullscreen mode Exit fullscreen mode

No launch. Just let withContext suspend until it’s done.


2. Use coroutineScope inside withContext if you need multiple launches

withContext(Dispatchers.IO) {
    coroutineScope {
        launch {
            delay(1000)
            println("Task 1 done")
        }
        launch {
            delay(500)
            println("Task 2 done")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

Task 2 done  
Task 1 done  
After all tasks
Enter fullscreen mode Exit fullscreen mode

coroutineScope ensures that withContext won’t finish until all its child coroutines have completed.


Bad Nesting in Real Life

1. Orphaned Coroutine Example

withContext(Dispatchers.IO) {
    launch {
        delay(1000)
        println("Still running after withContext ends 😵")
    }
    println("withContext done")
}
println("runBlocking done")
Enter fullscreen mode Exit fullscreen mode

Output:

withContext done  
runBlocking done  
Still running after withContext ends 😵
Enter fullscreen mode Exit fullscreen mode

2. Race Condition Example

withContext(Dispatchers.IO) {
    launch {
        delay(1000)
        println("Updating shared resources")
    }
    println("Assuming updates are done 🤡")
}
println("Reading shared resources 😬")
Enter fullscreen mode Exit fullscreen mode

🧾 Output:

Assuming updates are done 🤡  
Reading shared resources 😬  
Updating shared resources
Enter fullscreen mode Exit fullscreen mode

Yikes.


Final Thoughts

Bad nesting is sneaky because it looks innocent, but it quietly breaks everything structured concurrency stands for.

Next time you're inside a withContext, ask yourself:

“Am I doing the work, or am I delegating it?”

If it's the latter, make sure you're supervising the workers properly using coroutineScope. 😉


✍️ About the Author

Hey! I’m Abizer, an Android developer who’s into finding weird bugs that make for great blog posts.

If this helped you out, follow me here or connect on GitHub / LinkedIn.


Have you been bitten by bad coroutine nesting? Share your bug story in the comments!

Comments 3 total

Add comment