This content originally appeared on DEV Community and was authored by Abizer
“Wait… why is this coroutine still running after my function ended?”
– Me, a few months ago, squinting at logs and questioning reality
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")
}
Output:
Before withContext
withContext block done
After withContext
Inside launch
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:
-
runBlocking
starts your main coroutine. -
withContext(IO)
suspends and shifts work to an IO thread. - Inside that block, you call
launch
. This creates a new coroutine, not tracked bywithContext
. -
withContext
runs the block, hits the last line (println
) and… finishes. - 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")
}
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")
}
}
}
Output:
Task 2 done
Task 1 done
After all tasks
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")
Output:
withContext done
runBlocking done
Still running after withContext ends 😵
2. Race Condition Example
withContext(Dispatchers.IO) {
launch {
delay(1000)
println("Updating shared resources")
}
println("Assuming updates are done 🤡")
}
println("Reading shared resources 😬")
Output:
Assuming updates are done 🤡
Reading shared resources 😬
Updating shared resources
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!
This content originally appeared on DEV Community and was authored by Abizer