The Seven Levels of Kotlin Coroutines: From launch(IO) to Execution Model Design
Many Android developers follow almost the same learning path with coroutines.
From the very beginning:
Just make it run.
To the end:
Start designing asynchronous boundaries, scheduling strategies, and data flow models.
This is a very clear cognitive upgrade process.
Level 1: launch(IO) Everywhere
When first encountering coroutines, most people's code looks like this:
button.setOnClickListener {
CoroutineScope(Dispatchers.IO).launch {
val user = api.getUser()
withContext(Dispatchers.Main) {
tv.text = user.name
}
}
}
Or:
GlobalScope.launch(Dispatchers.IO) {
}
The project is full of:
launch(IO)
launch(IO)
launch(IO)
launch(IO)
Characteristics:
- Whatever is time-consuming goes to IO
- Whatever updates the UI goes to Main
- ViewModel, UseCase, Repository all switch threads
- Every layer thinks it should be responsible for threading
The result:
Main
↓
IO
↓
IO
↓
IO
↓
Main
There are more thread switches than business logic.
The core understanding at this stage:
Coroutine = creating a thread.
In reality:
A coroutine is never just a thread management framework.
Level 2: withContext() Everywhere
suspend fun loadUser(): User {
return withContext(Dispatchers.IO) {
api.getUser()
}
}
Then:
suspend fun queryUser(): User {
return withContext(Dispatchers.IO) {
dao.query()
}
}
And then:
suspend fun updateUser() {
withContext(Dispatchers.IO) {
dao.insert()
}
}
Finally the project becomes:
withContext(IO)
withContext(IO)
withContext(IO)
withContext(IO)
Even:
withContext(IO) {
repository.load()
}
repository:
withContext(IO) {
remote.load()
}
remote:
withContext(IO) {
retrofit.load()
}
Thread path:
Main
↓
IO-1
↓
IO-2
↓
IO-3
Although it's a bit better than launch(IO),
the essence is still:
Using coroutines as a thread-switching framework.
Level 3: Starting to Define suspend
At this stage, one begins to realize:
Asynchronous capability should sink downward.
Start writing:
interface UserRepository {
suspend fun getUser(): User
}
Instead of:
fun getUser(callback: (User) -> Unit)
Or:
fun getUser(): Deferred<User>
ViewModel:
viewModelScope.launch {
val user = repository.getUser()
}
Repository:
override suspend fun getUser(): User {
return api.getUser()
}
At this point, one begins to understand:
suspenddescribes asynchronous capability.
And also begins to understand:
Synchronous writing style
Asynchronous execution
This is how coroutines express asynchrony.
Level 4: Starting to Understand Main Safety
Digging deeper reveals that the one truly responsible for threading should be:
Data Layer
Not:
ViewModel
For example:
override suspend fun getUser(): User {
return dao.query()
}
So the upper layer becomes:
viewModelScope.launch {
val user = repository.getUser()
}
The ViewModel doesn't need to know whether these are blocking:
- Room
- Retrofit
- File IO
- ContentProvider
- Binder
This is:
Main Safe
The caller doesn't need to care about threads.
This has already entered the architectural design phase.
Level 5: Starting to Understand "Not All suspend Needs IO"
Many people eventually discover that the following is completely unnecessary:
withContext(IO) {
retrofitApi.load()
}
Because:
Retrofit suspend
OkHttp Dispatcher
are already asynchronous themselves.
Room:
Room Query Executor
DataStore:
DataStore Scope
Coil:
ImageLoader Dispatcher
Many SDKs:
Own thread pool
Wrapping another layer of withContext(IO) at this point often just creates:
Main
↓
IO Dispatcher
↓
SDK Thread Pool
An extra dispatch relay station.
This is also why many people see a crazy growth in thread count:
DefaultDispatcher-worker-1
DefaultDispatcher-worker-2
OkHttp Dispatcher
Room Executor
Level 6: Starting to Design Execution Models, Not Dispatchers
Truly advanced coroutine developers rarely discuss:
Should I use IO here or not?
Instead, they are thinking:
Who owns the execution right?
For example:
Application:
private val appExecutor =
Executors.newFixedThreadPool(4)
val applicationScope =
CoroutineScope(
SupervisorJob() +
appExecutor.asCoroutineDispatcher()
)
All business tasks:
applicationScope.launch {
}
Truly blocking work:
withContext(ioDispatcher) {
}
The entire App:
- Maximum thread count is controllable
- Scheduling model is predictable
- Won't end up with dozens of worker threads
This has begun to enter:
Execution model design
Instead of:
Thread switching tricks
Level 7: Forget Dispatchers, Start Thinking About Boundaries
Finally, one discovers that only three questions truly matter for coroutines:
Who is responsible for the lifecycle?
viewModelScope
applicationScope
lifecycleScope
Who is responsible for cancellation?
Job
SupervisorJob
Who is responsible for execution?
Retrofit
Room
DataStore
AppExecutor
And:
Dispatchers.IO
Dispatchers.Default
appear less and less frequently.
The Final Realm
From:
Coroutine = Creating a thread
To:
Coroutine = Asynchronous programming
Then to:
Coroutine = Lifecycle management
And finally to:
Coroutine = Execution model design
This is basically the cognitive upgrade path for an Android developer going from junior to senior, and then to architect, regarding coroutines.
Many people study coroutines for years but still remain stuck on:
Should this interface switch to IO?
While the real question has long since become:
Who should execute this task?
Who should own the scheduling right?
Who should own the lifecycle?
When one starts asking these questions, it means they have begun to truly understand coroutines.
References
From Callback to Coroutines: The Evolution of Android Asynchronous Concurrency Solutions
Why Modern Android Officially Recommends Repository Expose suspend fun Instead of launch Internally
Stop launch(IO): 3 Hidden Anti-patterns of Coroutine Thread Switching
Getting Started with Android Clean Architecture with a Small Demo
Modern Android Architecture Doesn't Need an Event Bus
Why I Don't Handle Exceptions Directly in Android ViewModel?