Stop Using launch(Dispatchers.IO) as a Thread Switch
launch is not for switching threads: why I write Dispatchers.IO after it less and less
In Kotlin coroutine development, code like this can be seen almost everywhere:
viewModelScope.launch(Dispatchers.IO) {
val user = repository.getUser()
}
Many developers have become accustomed to this pattern.
Need to make a network request?
launch(Dispatchers.IO)
Need to access the database?
launch(Dispatchers.IO)
Need to call a Repository?
launch(Dispatchers.IO)
Over time, launch(Dispatchers.IO) seems to have become the "standard template" in coroutine code.
But as my understanding of coroutines and asynchronous execution models deepens, I want to say:
Most
launchcalls do not needDispatchers.IO.
Because:
The core value of
launchis not "switching threads," but "creating an asynchronous task."
And the data layer should be responsible for its own underlying execution model.
1. Have we misunderstood launch?
Let's look at the simplest example first:
viewModelScope.launch {
val user = repository.getUser()
updateUi(user)
}
Many people's first reaction is:
Without
Dispatchers.IOhere, isn't the network request running on the Main thread?
This question itself actually exposes a misunderstanding.
We have bound:
launch
and:
thread switching
too tightly together.
In fact, the core semantics of launch is:
Create a new child coroutine, that is, a new asynchronous task.
For example:
coroutineScope {
launch {
loadUser()
}
launch {
loadConfig()
}
}
What really matters here is:
coroutineScope
/ \
/ \
launch A launch B
│ │
loadUser() loadConfig()
\ /
\ /
All completed
We use launch because we need two concurrent tasks.
The focus here is:
Concurrency.
So a more accurate understanding should be:
launch
↓
Create a new Coroutine
↓
Create a new Job
↓
Participate in structured concurrency
↓
Let the Dispatcher decide how to schedule
Therefore:
launch(Dispatchers.IO)
is not:
"launch means switching to the IO thread."
but should be understood as:
Create a new concurrent task and specify that it should be scheduled using
Dispatchers.IO.
These are two different concepts.
2. launch and withContext solve different problems
If we just need to execute a piece of code in a certain Context:
withContext(Dispatchers.IO) {
blockingOperation()
}
it expresses a completely different intent from:
launch(Dispatchers.IO) {
blockingOperation()
}
withContext is closer to:
The current task, temporarily using another Context to execute this piece of code.
while launch is closer to:
Create a new child task.
It can be simply understood as:
withContext
↓
I am still the current task
↓
Just switch to another Context for execution
and:
launch
↓
I want to create a new task
↓
Let it participate in concurrent execution
So:
val user = withContext(Dispatchers.IO) {
database.queryUser()
}
expresses:
I need to execute an IO operation, and continue the current flow after execution.
while:
launch(Dispatchers.IO) {
database.queryUser()
}
expresses:
I want to create a new concurrent task now.
These two requirements are inherently different.
Therefore:
Don't treat
launchas a thread-switching API.
3. Why do I write launch(Dispatchers.IO) less and less?
Because in modern Android's Data layer, many APIs already possess their own asynchronous execution models.
For example, Retrofit:
interface UserApi {
@GET("user")
suspend fun getUser(): User
}
Repository:
class UserRepository(
private val api: UserApi
) {
suspend fun getUser(): User {
return api.getUser()
}
}
ViewModel:
viewModelScope.launch {
val user = repository.getUser()
updateUi(user)
}
Here:
viewModelScope.launch
is responsible for:
Launching a coroutine task whose lifecycle belongs to the ViewModel.
and:
repository.getUser()
is responsible for:
Getting data.
The actual network request execution model is handled by the Retrofit/OkHttp layer.
Therefore, we don't need to mechanically write:
viewModelScope.launch(Dispatchers.IO) {
repository.getUser()
}
Because:
The ViewModel does not own the execution model of the network request.
It is merely initiating a task.
4. The Data layer should be the owner of the execution model
This is also an architectural principle I consider very important:
Whoever owns the execution model is responsible for scheduling.
For example, if the Data layer uses an asynchronous API:
suspend fun getUser(): User {
return api.getUser()
}
then:
ViewModel
↓
launch
↓
Repository
↓
Retrofit suspend API
↓
Underlying asynchronous execution
The upper layer does not need to add:
Dispatchers.IO
because the underlying layer already has its own execution model.
However, if the Data layer uses a blocking API:
fun queryUser(): User {
return blockingDatabase.query()
}
then the Data layer should be responsible for isolating the blocking operation:
suspend fun queryUser(): User {
return withContext(Dispatchers.IO) {
blockingDatabase.query()
}
}
This way, the upper layer can still:
viewModelScope.launch {
val user = repository.queryUser()
}
The entire call chain becomes:
ViewModel
│
│ launch
│
▼
Repository
│
│ Discovers the underlying is a Blocking API
│
▼
withContext(IO)
│
▼
Blocking IO
This forms a very clear boundary of responsibility:
ViewModel is responsible for launching tasks.
Data layer is responsible for ensuring the API is Main-safe.
The underlying execution model determines whether a Dispatcher is really needed.
5. The Data layer should guarantee Main-safe
What the Data layer should achieve is:
The caller does not need to know how the Data layer executes internally.
For example:
viewModelScope.launch {
repository.getUser()
}
No matter where this launch runs by default, the call must not lead to:
Main Thread
↓
Repository
↓
Blocking IO
↓
ANR
Therefore, the Data layer's contract should be:
Even if the caller calls me on Main, I will not block Main.
This is Main-safe.
For example:
class UserRepository {
suspend fun getUser(): User {
return withContext(Dispatchers.IO) {
blockingApi.getUser()
}
}
}
Then the caller can simply:
viewModelScope.launch {
repository.getUser()
}
without needing to care whether it internally uses:
Dispatchers.IO
or:
Dispatchers.Default
Even if the underlying API changes to a truly asynchronous API in the future:
suspend fun getUser(): User {
return asyncApi.getUser()
}
the upper layer code does not need to change.
This is the isolation of responsibilities.
6. If the underlying is already asynchronous, wrapping another layer of IO is just scheduling redundancy
Suppose the underlying API is already asynchronous:
suspend fun getUser(): User
Upper layer:
viewModelScope.launch(Dispatchers.IO) {
repository.getUser()
}
In many cases, this Dispatchers.IO does not solve a real problem.
Because:
ViewModel
↓
launch(IO)
↓
Repository
↓
Suspend API
↓
Underlying asynchronous execution model
A "scheduling redundancy" appears here:
Upper layer schedules once
↓
Underlying layer schedules itself
↓
Actual execution
The upper layer just added a layer of IO for "safety."
But in fact:
It is just one kind of Dispatcher in the Kotlin coroutine ecosystem.
The question that really needs attention should be:
Is the underlying API blocking or not?
If it is blocking:
Blocking
↓
Needs isolation
↓
May use IO
If it is already asynchronously suspending:
Suspend / Async
↓
Already has an execution model
↓
Usually does not need the upper layer to switch to IO additionally
So, instead of asking:
"Does this request need to use
Dispatchers.IO?"
it's better to first ask:
"What is the execution model of this API?"
7. Don't let launch(IO) become a conditioned reflex
Code like this:
viewModelScope.launch(Dispatchers.IO) {
repository.getUser()
}
should make us ask one more question:
Why must it be IO here?
instead of:
Why is there no IO here?
If the answer is just:
"Because it's a network request."
then maybe we need to rethink.
If the answer is:
"Because the Repository internally calls a blocking API."
then should this IO be the responsibility of the Repository internally?
If the answer is:
"Because it's a database operation."
then is this database API blocking, or is it already suspend / Flow?
If the answer is:
"Because everyone writes it this way."
then maybe we should delete it.
One of the true signs of mature coroutine code is:
Starting to reduce those Dispatchers without a clear reason.
8. Re-understanding the three core roles of Coroutines
I think the three concepts in coroutines can be simply separated:
CoroutineScope
↓
Lifecycle boundary
launch
↓
Create concurrent task
Dispatcher
↓
Scheduling strategy
Therefore:
viewModelScope.launch {
repository.getUser()
}
expresses:
ViewModel lifecycle
↓
Create a task
↓
Call Repository
and:
withContext(Dispatchers.IO) {
blockingOperation()
}
expresses:
Current task
↓
Execute a blocking operation
↓
Need to use IO scheduling strategy
If everything is written as:
launch(Dispatchers.IO)
it actually mixes:
Lifecycle
Concurrency
Scheduling
all three concepts together.
Although the final code looks simple, the boundaries of responsibility become blurred instead.
9. The way of thinking I recommend more now
I no longer see:
launch {
}
and my first reaction is:
"Is
Dispatchers.IOmissing here?"
I will first ask three questions:
First: Do I need concurrency?
If yes:
launch
If not, and I just want to change the execution Context:
withContext
Second: Is the underlying API blocking?
If yes:
withContext(Dispatchers.IO)
or let the Data layer closer to the blocking source handle it.
If it is already:
suspend
or:
Flow
and the underlying already has a reasonable asynchronous execution model, then there is usually no need to mechanically wrap another layer of IO.
Third: Who should be responsible for scheduling?
My answer is:
Whoever owns the execution model is responsible for scheduling.
The ViewModel should not decide by default what Dispatcher the entire call chain uses.
The Data layer should guarantee Main-safe.
The underlying library should be responsible for its own execution model.
The upper layer only needs to express business concurrency relationships.
Summary
The problem coroutines truly solve has never been just "thread switching."
If we understand coroutines as:
Thread
↓
Replace with
Coroutine
then we will eventually fall back into the mindset of:
launch(Dispatchers.IO)
launch(Dispatchers.Default)
withContext(Dispatchers.IO)
constantly switching Dispatchers.
But from the perspective of structured concurrency, the core of coroutines is actually:
Scope
↓
Manage lifecycle
launch
↓
Create concurrent task
withContext
↓
Change execution Context
Dispatcher
↓
Provide scheduling strategy
Job
↓
Manage task relationships and cancellation
So:
The value of
launchis not "switching threads," but "creating concurrent tasks."
The value of
withContextis not "starting asynchrony," but "executing code in another Context."
Dispatchers.IOis not the "asynchronous switch" of coroutines, but a scheduling strategy.
And in modern Android applications, if the underlying APIs used by the Data layer are already asynchronous, then in most cases the ViewModel layer can simply:
viewModelScope.launch {
repository.getUser()
}
Handing:
Concurrency
to launch,
handing:
Execution model
to the Data layer and underlying libraries,
handing:
Scheduling strategy
to the layer that truly owns the execution model.
We will eventually find:
Good coroutine code should not have
Dispatchers.IOwritten everywhere, but should let each layer only be responsible for what it truly should be responsible for.