Chapter 1 — The Concurrency Problem on Android
Before you can appreciate why coroutines exist, you have to feel the pain they were built to solve. This chapter is that pain.
1.1 Why Android Forces You to Think About Threads
Every Android app starts life on a single thread: the main thread, also called the UI thread. This thread does two jobs that turn out to be in direct conflict with each other.
First, it draws your UI. Android targets 60 frames per second on most devices (120 on newer ones), which gives you roughly 16 milliseconds to produce a single frame. Measure, lay out, draw — all of it, every frame.
Second, the main thread is the only thread allowed to touch your views. Updating a TextView, reacting to a tap, animating a transition — these must happen on the main thread.
Now put those two facts together. If you run a slow operation — a network call, a database query, parsing a large JSON payload — on the main thread, the thread is busy and cannot draw frames. The UI freezes. Stay blocked for about five seconds and the system shows the dreaded ANR dialog: Application Not Responding.
So the rule is simple to state and surprisingly hard to follow:
Never block the main thread. But only the main thread may update the UI.
That tension — "get off the main thread to do work, then get back on it to show the result" — is the root of essentially every concurrency tool Android has ever shipped. Coroutines are the latest and best answer, but to understand why they're shaped the way they are, we need to walk through the answers that came before.
1.2 A Concrete Example: Loading a User Profile
Let's anchor the discussion in a task you've written a hundred times. We want to:
- Fetch a user from the network.
- Load that user's avatar image.
- Save the result to a local cache.
- Show it on screen.
In a perfect, synchronous world the code would read like a grocery list:
fun loadProfile(userId: String) {
val user = api.fetchUser(userId) // network
val avatar = api.fetchAvatar(user.url) // network
database.save(user, avatar) // disk
showProfile(user, avatar) // UI
}
This is beautiful. It reads top to bottom. The data dependencies are obvious. If something throws, a single try/catch wraps the whole thing.
There is exactly one problem: every line of it blocks the main thread, and three of those four lines are slow. Run this as written and your app freezes for the duration of two network calls and a disk write. This code is correct in logic and catastrophic in practice.
The entire history of Android concurrency is a series of attempts to keep this readability while moving the slow parts off the main thread. Let's see how each attempt did.
1.3 Attempt One: Raw Threads
The most direct fix is to spawn a thread:
fun loadProfile(userId: String) {
Thread {
val user = api.fetchUser(userId)
val avatar = api.fetchAvatar(user.url)
database.save(user, avatar)
runOnUiThread {
showProfile(user, avatar) // back on main for UI
}
}.start()
}
This works. The slow work happens on a background thread, and we hop back to the main thread with runOnUiThread to touch views. So why don't we write Android apps this way?
A few reasons, and they compound:
- Threads are expensive. Each one costs around 1–2 MB of stack memory and real scheduling overhead from the OS. Spawn one per screen-load in a list and you'll feel it.
- There is no lifecycle awareness. If the user rotates the device or navigates away mid-fetch, that thread keeps running. When it finally calls
showProfile, the Activity may be destroyed — hello, crash or memory leak. - Cancellation is a nightmare. There's no clean way to say "stop, I don't need this anymore."
Thread.interrupt()is cooperative, awkward, and widely misused. - Composition is manual. Running two fetches in parallel, then combining them, means juggling threads, locks, and shared mutable state by hand.
Raw threads give you power and hand you every footgun that comes with it.
1.4 Attempt Two: Callbacks
The next idea: don't block at all. Hand the slow operation a function to call when it's done. This is the callback model, and for years it was the backbone of Android networking (think old-school Volley, or Retrofit's Call.enqueue).
fun loadProfile(userId: String) {
api.fetchUser(userId) { user ->
api.fetchAvatar(user.url) { avatar ->
database.save(user, avatar) {
runOnUiThread {
showProfile(user, avatar)
}
}
}
}
}
Look at what happened to our clean grocery list. Each operation that depends on the previous result has to nest inside the previous callback. The code now grows sideways. This rightward drift has a name developers say with a sigh: callback hell, or the pyramid of doom.
It isn't just ugly. It's genuinely harder to reason about:
fun loadProfile(userId: String) {
api.fetchUser(userId,
onSuccess = { user ->
api.fetchAvatar(user.url,
onSuccess = { avatar ->
database.save(user, avatar,
onSuccess = { showProfile(user, avatar) },
onError = { showError(it) } // error 3
)
},
onError = { showError(it) } // error 2
)
},
onError = { showError(it) } // error 1
)
}
Now we've handled errors, and the structure has exploded. Notice the three separate onError branches — there's no single place to catch a failure. Control flow that a synchronous try/catch expresses in one block is now scattered across three nesting levels.
Callbacks broke the most valuable property our original code had: it read like the order things happen. Sequential logic became a tree.
1.5 The Three Things That Make Async Hard
Step back from the specific tools and notice the pattern. Callbacks and raw threads each fail at one or more of three fundamental challenges. Any good concurrency solution has to nail all three:
1. Sequencing. "Do A, then use its result to do B." Synchronous code expresses this for free — line after line. Callbacks turn it into nesting.
2. Error handling. A failure anywhere in a chain should be catchable in one place, the way try/catch wraps a synchronous block. Callbacks force error handling at every level.
3. Cancellation. When the user walks away, the work should stop and clean up — no wasted CPU, no leaked references to a dead screen. Threads and callbacks have no built-in answer here.
Keep these three in your back pocket. Coroutines win precisely because they address all three at once, and we'll return to this list as a scorecard throughout the book.
1.6 A Quick Detour: What About RxJava?
If you worked on Android between roughly 2015 and 2019, you probably reached for RxJava to escape callback hell. And it genuinely helped. Rx lets you express our profile load as a chain:
fun loadProfile(userId: String) {
api.fetchUser(userId)
.flatMap { user -> api.fetchAvatar(user.url).map { user to it } }
.flatMap { (user, avatar) -> database.save(user, avatar).andThen(Single.just(user to avatar)) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ (user, avatar) -> showProfile(user, avatar) },
{ error -> showError(error) }
)
}
This is a real improvement. Sequencing flattens into a chain, errors funnel into one place, and disposing the subscription cancels the work. Rx solved all three challenges.
The cost was a steep learning curve and a giant operator vocabulary — flatMap, concatMap, switchMap, combineLatest, zip, and dozens more, each with subtle behavioral differences. You essentially had to learn a second language layered on top of Kotlin, where everything became a stream even when you just wanted a single value.
Coroutines arrived with a compelling pitch: what if asynchronous code could just look like synchronous code again? No new mental model of streams for one-shot operations, no operator zoo to memorize — just your familiar grocery list, with the blocking quietly removed.
1.7 The Coroutine Promise
Here is our profile loader written with coroutines. I'm showing it now, before explaining a single piece of the machinery, because the shape is the entire point:
suspend fun loadProfile(userId: String) {
val user = api.fetchUser(userId) // network, doesn't block
val avatar = api.fetchAvatar(user.url) // network, doesn't block
database.save(user, avatar) // disk, doesn't block
showProfile(user, avatar) // UI
}
Compare that to where we started in Section 1.2. It is, line for line, almost identical to the "beautiful but catastrophic" synchronous version. The grocery list is back. It reads top to bottom. The data dependencies are obvious. A single try/catch can wrap the whole thing:
suspend fun loadProfile(userId: String) {
try {
val user = api.fetchUser(userId)
val avatar = api.fetchAvatar(user.url)
database.save(user, avatar)
showProfile(user, avatar)
} catch (e: IOException) {
showError(e) // one place, every failure
}
}
The difference from the catastrophic version is a single keyword — suspend — and the promise it carries: none of these slow lines block the thread. When fetchUser is waiting on the network, the thread is released to do other work (like drawing frames). When the response arrives, execution resumes right where it left off, on the line after fetchUser.
That word, suspend, is doing enormous work, and the whole of Chapter 2 is devoted to understanding exactly what it means and how it's possible. For now, hold onto the feeling: coroutines let you write asynchronous code that looks synchronous, while quietly solving all three challenges from Section 1.5.
| Challenge | Threads | Callbacks | RxJava | Coroutines |
|---|---|---|---|---|
| Sequencing reads top-to-bottom | ✅ | ❌ | ⚠️ (chains) | ✅ |
| Single-place error handling | ❌ | ❌ | ✅ | ✅ |
| Built-in cancellation | ❌ | ❌ | ✅ | ✅ |
| Cheap to create | ❌ | ✅ | ✅ | ✅ |
| Familiar mental model | ✅ | ⚠️ | ❌ | ✅ |
1.8 What "Coroutine" Actually Means
The word predates Kotlin by decades. A coroutine is a generalization of a subroutine (an ordinary function). A regular function has two operations: you call it, and it returns. Once it returns, it's done; call it again and it starts over from the top.
A coroutine adds two more operations: it can suspend (pause itself, giving control back to whoever is running it) and later resume (pick up exactly where it paused, with all its local state intact). That ability to pause and resume in the middle is the whole idea.
The crucial consequence for Android: suspending is not blocking. When a coroutine suspends to wait for the network, it doesn't hold the thread hostage. The thread is free to run other coroutines or, on Android, to keep rendering the UI. One thread can serve thousands of suspended coroutines, because suspended coroutines cost almost nothing — they're just objects holding their paused state, not threads sitting idle.
This is why people say coroutines are lightweight. You can launch 100,000 of them on a laptop without trouble; try that with threads and you'll run out of memory long before you finish. We'll see the mechanism behind this in the next chapter — it comes down to the compiler transforming your suspend functions into state machines — but the headline is: suspension is cheap, blocking is expensive, and coroutines suspend.
1.9 Coroutines Are a Library, Not Just Language Magic
One source of confusion worth clearing up early: "coroutines" in Kotlin are really two things working together.
The language provides exactly one primitive: the suspend keyword and the compiler support behind it. That's it. The compiler knows how to transform suspending functions so they can pause and resume.
Everything else — launch, async, CoroutineScope, Dispatchers, Flow, Channel — lives in a library called kotlinx.coroutines, maintained by JetBrains. You add it as a dependency:
// build.gradle.kts (module level)
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") // Main dispatcher for Android
}
The -android artifact is what gives you a Dispatchers.Main wired to Android's main looper. Without it, the library still works, but it won't know how to get back onto the UI thread.
This split matters because it explains the design philosophy. The language stays minimal and unopinionated; the rich, evolving toolkit lives in a versioned library that can improve independently. When you read that a function "is a coroutine builder" or that Flow "is part of coroutines," you now know that means part of the library, not a language feature.
1.10 What We'll Build Toward
This book follows the same arc we just walked through, in reverse: instead of suffering the problems and arriving at coroutines, we'll start from coroutines and build outward into everything a production Android app needs.
- Part 2 dissects the core machinery: what suspension really is, the builders that start coroutines, scopes and context, structured concurrency, dispatchers, cancellation, and exceptions.
- Part 3 moves from single values to streams of values with
Flow— cold flows, operators, and the hotStateFlow/SharedFlowthat power modern UI state. - Part 4 plants all of this firmly in Android:
viewModelScope, lifecycle-aware collection, Jetpack Compose, and your data layer (Room, Retrofit, DataStore). - Part 5 covers what separates a demo from a shipped app: testing, debugging, and battle-tested patterns like retry, polling, and pagination.
By the end, the four-line loadProfile from Section 1.7 won't look like magic. You'll know precisely what each line does, where it runs, how it fails, and how to test it.
1.11 Summary
- The Android main thread must stay free to render frames every ~16 ms, yet it's the only thread allowed to update the UI. That tension is the source of all concurrency complexity.
- Blocking the main thread freezes the app and triggers ANRs. Slow work must move off it — but results must come back to it.
- Raw threads are expensive, lifecycle-blind, and hard to cancel.
- Callbacks avoid blocking but collapse into callback hell, scattering sequencing and error handling.
- Any real solution must solve three challenges at once: sequencing, error handling, and cancellation.
- RxJava solved all three but demanded a whole new streams-based mental model.
- Coroutines solve all three while letting asynchronous code look synchronous again — the key is the
suspendkeyword, which lets a coroutine pause without blocking its thread. - Coroutines are a small language feature (
suspend) plus a rich library (kotlinx.coroutines).
In the next chapter, we stop hand-waving about suspend and find out exactly what it means for a function to pause and resume — and why that's possible without blocking a thread.
Next: Chapter 2 — Your First Coroutine, where we write real coroutines, meet suspend functions for real, and uncover what suspension looks like under the hood.