From Callback Hell to Structured Concurrency with Kotlin Coroutines
Nested callbacks make async Kotlin code hard to read and easy to leak. Suspend functions and structured concurrency let you write sequential-looking code that cancels safely when its scope dies.
29 Sept 2025, 11:25 UTC

The callback trap
Async code often starts simple: one network call, one callback. Then the response triggers a second request, the second triggers a database write, and suddenly your function is four lambdas deep. Each level needs its own error handling, and if the user leaves the screen you must manually unsubscribe every listener. Kotlin coroutines attack this problem directly. The useful takeaway: write asynchronous logic as sequential-looking suspend functions, and let structured concurrency cancel the whole tree when its owning scope dies.
Suspend functions: sequential code without blocking
A suspend function is a normal Kotlin function marked with the suspend keyword. It can pause execution at a suspension point—such as waiting for a network response—without blocking the underlying thread. The thread returns to the pool and can run other work. To the reader, the code looks top-to-bottom, like synchronous code, but the runtime schedules it cooperatively.
The distinction matters. Calling Thread.sleep inside a coroutine blocks the thread and can starve the dispatcher’s thread pool. Calling delay suspends the coroutine and frees the thread. Dispatchers—Dispatchers.Main, Dispatchers.IO, Dispatchers.Default—let you shift execution to the right context, for example moving disk I/O off the UI thread.
Structured concurrency: scopes own lifetimes
Every coroutine runs inside a CoroutineScope. The scope holds a Job that acts as the parent of any coroutine launched within it. When you cancel the parent Job, cancellation propagates to all children. This is structured concurrency: coroutine lifetimes are tied to the component that created them, which prevents the classic leak where a background task outlives the screen that requested it.
The coroutine builder launch starts fire-and-forget work. The builder async starts work that produces a value later; you retrieve that value with await, which suspends until the result is ready. Because both builders respect the parent scope, cancelling the scope cancels the async work too.
Worked example: flattening a callback chain
Suppose you have a legacy Java callback API that loads a user. The nested version is hard to read and easy to leak:
fun loadUserCallbacks(
onSuccess: (User) -> Unit,
onError: (Throwable) -> Unit
)
You can bridge it into a suspend function with suspendCancellableCoroutine. The continuation object resumes the suspended caller when the callback fires:
suspend fun loadUser(): User = suspendCancellableCoroutine { continuation ->
loadUserCallbacks(
onSuccess = { user -> continuation.resume(user) },
onError = { error -> continuation.resumeWithException(error) }
)
}
Now the caller reads sequentially. If you need two independent resources—say a user profile and a news feed—you can run them concurrently with async and await both:
class DashboardLoader(private val scope: CoroutineScope) {
fun load() {
scope.launch {
val userDeferred = async { loadUser() }
val feedDeferred = async { loadFeed() }
val dashboard = Dashboard(userDeferred.await(), feedDeferred.await())
render(dashboard)
}
}
}
If scope is cancelled—because a ViewModel is cleared or a request is aborted—both async coroutines are cancelled automatically. No manual unsubscribe list.
Where the dependency goes
In your module-level build.gradle.kts, add the core coroutines library:
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core")
}
Replace the coordinate with the current stable version from the kotlinx-coroutines release notes. After syncing, the import kotlinx.coroutines.* should resolve. The main risk is a version mismatch between the Kotlin compiler and the coroutines library, so check compatibility before pinning.
Trade-offs and sharp edges
- GlobalScope is a leak waiting to happen. Coroutines launched in
GlobalScopelive for the entire application lifetime. They are not cancelled when a screen or component is destroyed. - Blocking calls neutralize the benefit. A single
Thread.sleepor blocking file read inside a coroutine can occupy a dispatcher thread and undo the non-blocking model. - Legacy callbacks need explicit cancellation support.
suspendCancellableCoroutineis cancellable, but the underlying callback API must also expose a cancel or unsubscribe method; otherwise the coroutine may suspend forever. - Uncaught exceptions can crash the scope. A
CoroutineExceptionHandlerinstalled in the scope context provides a centralized place to log or recover, but it does not make the coroutine itself survive.
A practical way to check the result
To confirm cancellation propagation, create a small scratch test or main function. Build a scope with an explicit Job, launch a child coroutine that calls delay, cancel the parent Job, and verify the child never completes. The pattern looks like this:
val parentJob = Job()
val scope = CoroutineScope(parentJob)
scope.launch {
delay(10_000)
}
parentJob.cancel() // child is cancelled too
For the bridging example, assert that loadUser() returns the same value the callback would have delivered, and that cancelling the calling coroutine invokes the callback API’s cleanup path if one exists. Because coroutine APIs evolve, verify the exact signatures and builder behavior against the current kotlinx-coroutines documentation before shipping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.