Skip to content

How to resolve withContext not returning to main thread in Kotlin coroutines

Problem

When I tried using withContext(Dispatchers.IO) in a Kotlin coroutine, I expected execution to return to the main thread after the block completed. Instead, the code resumed on a worker thread:

suspend fun main() {
println("Before: ${Thread.currentThread().name}") // main
withContext(Dispatchers.IO) {
println("Inside IO: ${Thread.currentThread().name}") // DefaultDispatcher-worker-1
}
println("After: ${Thread.currentThread().name}") // Expected: main, Actual: DefaultDispatcher-worker-1
}

Output:

Before: main
Inside IO: DefaultDispatcher-worker-1
After: DefaultDispatcher-worker-1

I thought withContext would automatically switch back to the original thread after finishing the IO work, but it stayed on the worker thread.

Environment

  • Kotlin 1.9
  • JVM (not Android)
  • kotlinx-coroutines-core 1.8

What happened?

I was writing a Kotlin coroutine that needed to do some blocking IO work, then continue execution on the main thread. My mental model was that withContext worked like a synchronous call:

Main thread calls withContext(IO)
Switches to IO thread, does work
Returns to main thread when done

Here’s what I wrote:

"CoroutineTest.kt
import kotlinx.coroutines.*
suspend fun main() = coroutineScope {
println("Main start: ${Thread.currentThread().name}")
val result = withContext(Dispatchers.IO) {
println("IO work: ${Thread.currentThread().name}")
delay(100)
"data
}
println("Result: $result")
println("Main end: ${Thread.currentThread().name}")
}

I thought after withContext completed, execution would resume on main. But when I ran it, I got:

Main start: main
IO work: DefaultDispatcher-worker-1
Result: data
Main end: DefaultDispatcher-worker-1

The final print was on DefaultDispatcher-worker-1, not main.

How to solve it?

I tried adding coroutineScope to preserve the dispatcher context:

suspend fun main() = coroutineScope {
println("Start: ${Thread.currentThread().name}")
launch {
// This inherits the coroutineScope's dispatcher
println("Launch start: ${Thread.currentThread().name}")
withContext(Dispatchers.IO) {
println("Inside IO: ${Thread.currentThread().name}")
}
println("Launch end: ${Thread.currentThread().name}")
}
println("End: ${Thread.currentThread().name}")
}

Output:

Start: main
End: main
Launch start: main
Inside IO: DefaultDispatcher-worker-1
Launch end: main

Now the code resumed on main after withContext. The coroutineScope wrapper preserved the dispatcher context for child coroutines.

I also tried being explicit about dispatcher switching:

suspend fun explicitDispatcherControl() {
withContext(Dispatchers.IO) {
println("IO: ${Thread.currentThread().name}")
}
// Explicitly switch back
withContext(Dispatchers.Default) {
println("Default: ${Thread.currentThread().name}")
}
}

This chained approach gives full control over which thread executes each block.

The reason

The key reason for this behavior is how Kotlin coroutines work under the hood:

Continuation-Passing Style: Coroutines don’t actually “return” to the caller’s thread. When a coroutine suspends, it captures a continuation (the code after the suspension point) and passes it to the dispatcher. When resuming, the dispatcher decides which thread executes that continuation.

Dispatcher inheritance: Child coroutines inherit their parent’s dispatcher unless explicitly overridden. When you use coroutineScope, children inherit the scope’s dispatcher. When you use withContext, you’re explicitly changing the dispatcher for that block.

No main thread in plain JVM: In plain Kotlin/JVM, there’s no Dispatchers.Main unless you’re using a UI framework (Android, JavaFX, Swing). The main function runs on the initial thread, but coroutines use Dispatchers.Default by default.

The dispatcher flow looks like this:

┌─────────────────────────────────────────────────────┐
│ coroutineScope (inherits main dispatcher) │
│ ┌───────────────────────────────────────────────┐ │
│ │ withContext(IO) │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Dispatches to worker thread │ │ │
│ │ │ Executes block │ │ │
│ │ │ Resumes continuation on worker thread │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ Continuation runs on worker thread │ │
│ └───────────────────────────────────────────────┘ │
│ Without coroutineScope: worker thread │
│ With coroutineScope: inherits original dispatcher │
└─────────────────────────────────────────────────────┘

In Android, Dispatchers.Main is a special dispatcher that ensures UI thread execution. When you launch from Main and use withContext(IO), the continuation automatically resumes on Main after IO completes. This doesn’t happen in plain JVM.

Summary

In this post, I showed how withContext dispatcher continuation actually works in Kotlin coroutines. The key point is that coroutines use continuation-passing style where resumption happens on the dispatcher’s thread, not the caller’s thread. To ensure code runs on a specific thread after withContext, use coroutineScope to preserve the dispatcher context or chain multiple withContext calls for explicit thread control.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments