svenjacobs
08/21/2025, 6:11 AMsvenjacobs
08/21/2025, 6:11 AMfun onButtonClick() {
mainScope.launch {
performNetworkRequestUseCase()
}
}
mainScope
is a singleton (injected) instance of MainScope
and performNetworkRequestUseCase
is a class that looks like this
suspend fun invoke() {
withContext(Dispatchers.IO) {
doNetworkRequest()
}
}
The default dispatcher of MainScope
is Main
so obviously anything that runs in there might interfere with UI rendering. Since I'm aware of that I used withContext(<http://Dispatchers.IO|Dispatchers.IO>)
in invoke
of the use case.
However, the stuttering only disappeared after I changed the code in the ViewModel to the following
fun onButtonClick() {
mainScope.launch(Dispatchers.IO) { // note the Dispatchers.IO here
performNetworkRequestUseCase()
}
}
Now I'm confused. Why do I have to explicitly declare a dispatcher for launch
when the suspending functions does the same with withContext
? Why does it make a difference? There is just this single call of the use case. There's no other operation in the coroutine.svenjacobs
08/21/2025, 6:13 AMMainScope
in the ViewModel and not the provided viewModelScope
because this operation should keep running even when the ViewModel was disposed.svenjacobs
08/21/2025, 6:42 AMmainScope.launch {
withContext(<http://Dispatchers.IO|Dispatchers.IO>) {
doNetworkRequest()
}
}
is different to
mainScope.launch(<http://Dispatchers.IO|Dispatchers.IO>) {
withContext(<http://Dispatchers.IO|Dispatchers.IO>) {
doNetworkRequest()
}
}
Does that make sense? ๐ตโ๐ซLukasz Kalnik
08/21/2025, 8:20 AM<http://Dispatchers.IO|Dispatchers.IO>
at all when calling the network request. The libraries do it for you under the hood.
2. If you need to have a request which survives ViewModel being canceled/cleared, then better solution is to use something like this
object ApplicationScope {
val instance = CoroutineScope(SupervisorJob())
}
Also I'm not sure how correct it is to launch a request, which needs to survive the ViewModel, on injected mainScope
inside a ViewModel. Wouldn't it hold a reference to the mainScope
instance of the destroyed ViewModel? Probably that's not an issue though, only the ViewModel cannot be garbage collected, but this should not be your problem here.
We do the requests which need to survive the ViewModel being destroyed inside a Repository. Repository lives the whole application lifecycle.svenjacobs
08/21/2025, 8:26 AMobject ApplicationScope {
val instance = CoroutineScope(SupervisorJob())
}
is any different to how I do it already. mainScope
is a singleton managed via dependency injection. MainScope
is a Coroutine with a SupervisorJob
so it's basically the same? Anyway, I don't think the issue is the injected mainScope
.
The network call is a Firebase Functions call
functions.getHttpsCallable("function").call().await()
where await
is a suspend
extension function on Task<T>
, which is returned by getHttpsCallable
.Lukasz Kalnik
08/21/2025, 8:27 AM<http://Dispatchers.IO|Dispatchers.IO>
?Lukasz Kalnik
08/21/2025, 8:27 AMsvenjacobs
08/21/2025, 8:27 AMLukasz Kalnik
08/21/2025, 8:29 AMMainScope
is different. It has Dispatchers.Main
. My ApplicationScope
doesn't set a dispatcher.Lukasz Kalnik
08/21/2025, 8:30 AM<http://Dispatchers.IO|Dispatchers.IO>
correctly for itself.Lukasz Kalnik
08/21/2025, 8:31 AM<http://Dispatchers.IO|Dispatchers.IO>
is only needed for blocking functions.svenjacobs
08/21/2025, 8:31 AMlaunch(<http://Dispatchers.IO|Dispatchers.IO>)
is required when there is already a withContext(<http://Dispatchers.IO|Dispatchers.IO>)
? Regardless of what Dispatcher the CoroutineScope has set, the withContext
should "override" that, no?svenjacobs
08/21/2025, 8:34 AMawait
from the kotlinx-coroutines-play-services
artifact (and awaitImpl
) does not set a Dispatcher.svenjacobs
08/21/2025, 8:36 AMTask
is from the Play Services SDK and does not necessarily have to be a network request.Lukasz Kalnik
08/21/2025, 8:37 AMawaitImpl
has to set a Dispatcher at all, because it's not blocking. It's just suspending a coroutine, that's different.Lukasz Kalnik
08/21/2025, 8:37 AMLukasz Kalnik
08/21/2025, 8:40 AMLukasz Kalnik
08/21/2025, 8:40 AMLukasz Kalnik
08/21/2025, 8:41 AMMainScope
sets a Dispatchers.Main
firstLukasz Kalnik
08/21/2025, 8:41 AMCalls to withContext whose context argument provides a CoroutineDispatcher that is different from the current one, by necessity, perform additional dispatches: the block can not be executed immediately and needs to be dispatched for execution on the passed CoroutineDispatcher, and then when the block completes, the execution has to shift back to the original dispatcher.
Lukasz Kalnik
08/21/2025, 8:42 AMDispatchers.Main
to <http://Dispatchers.IO|Dispatchers.IO>
svenjacobs
08/21/2025, 8:42 AMwithContext
apparently isn't working.svenjacobs
08/21/2025, 8:42 AMSo you have an (unnecessary) additional dispatch if you switch fromMight be true but I still want to know how and why this impacts thetoDispatchers.Main
<http://Dispatchers.IO|Dispatchers.IO>
Main
dispatcher.Lukasz Kalnik
08/21/2025, 8:42 AMsvenjacobs
08/21/2025, 8:43 AMLukasz Kalnik
08/21/2025, 8:43 AMLukasz Kalnik
08/21/2025, 8:44 AMLukasz Kalnik
08/21/2025, 8:44 AMMichael Paus
08/21/2025, 9:09 AMstreetsofboston
08/21/2025, 9:19 AMsvenjacobs
08/21/2025, 9:20 AMMichael Paus
08/21/2025, 9:27 AMobject ApplicationScope
any different from using GlobalScope
.Lukasz Kalnik
08/21/2025, 9:55 AMsvenjacobs
08/21/2025, 1:32 PMMainScope()
to CoroutineScope(SupervisorJob())
for our global application scope did actually "solve" the problem of having to specify a dispatcher for launch
, but I would still like to know why withContext
alone doesn't seem to work or rather has an impact on the main (UI) thread?Lukasz Kalnik
08/21/2025, 1:39 PMMain
dispatcher, and only then withContext
dispatches it on another thread. I don't know how big the impact is though and how much stuttering you experienced.CLOVIS
08/21/2025, 1:51 PMwithContext
call and leave the main thread available for whatever else? I don't understand how this would cause stuttering.svenjacobs
08/21/2025, 1:54 PMLukasz Kalnik
08/21/2025, 1:54 PMsvenjacobs
08/21/2025, 1:55 PMLukasz Kalnik
08/21/2025, 1:57 PMstreetsofboston
08/21/2025, 2:13 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 9:24 PMZach Klippenstein (he/him) [MOD]
08/21/2025, 9:25 PMwithContext
case the network call is happening on the main thread, but i'd want to add some logging or debug or profile to actually see what's causing the UI stutter before jumping to assumptions.Zach Klippenstein (he/him) [MOD]
08/21/2025, 9:25 PMsvenjacobs
08/22/2025, 7:20 AMstreetsofboston
08/22/2025, 7:22 AMsvenjacobs
08/22/2025, 7:26 AMsvenjacobs
08/22/2025, 7:29 AMstreetsofboston
08/22/2025, 7:43 AMRoman Golyshev
08/22/2025, 10:51 AMcoroutineContext
for both cases in your code?
Just doing println(coroutineContext)
inside of launch(<http://Dispatchers.IO|Dispatchers.IO>) { ... }
vs launch { withContext(<http://Dispatchers.IO|Dispatchers.IO>) { โฆ } }
would be enough
I just want to make sure that the correct CoroutineDispatcher
is indeed present in the contextsvenjacobs
08/22/2025, 12:20 PMlaunch
without any dispatcher where First context
is right after launch
and Second context
is inside the withContext(<http://Dispatchers.IO|Dispatchers.IO>)
First context [StandaloneCoroutine{Active}@5f2d8ef, Dispatchers.Main]
Second context [DispatchedCoroutine{Active}@e595ffc, <http://Dispatchers.IO]|Dispatchers.IO]>
and this is with launch(<http://Dispatchers.IO|Dispatchers.IO>)
First context [StandaloneCoroutine{Active}@f2a1881, <http://Dispatchers.IO]|Dispatchers.IO]>
Second context [kotlinx.coroutines.UndispatchedMarker@d29ed26, UndispatchedCoroutine{Active}@27fbd67, <http://Dispatchers.IO]|Dispatchers.IO]>
svenjacobs
08/22/2025, 12:45 PMstreetsofboston
08/22/2025, 12:47 PMLukasz Kalnik
08/22/2025, 12:56 PMsvenjacobs
08/22/2025, 12:59 PMDmitry Khalanskiy [JB]
09/11/2025, 9:23 AMkotlinx.coroutines
maintainer here.
The snippets you show are almost equivalent in their threading behavior, but not completely. Here's what happens:
In the original code:
fun onButtonClick() {
// <-- the caller thread
mainScope.launch {
// <-- the main thread
withContext(<http://Dispatchers.IO|Dispatchers.IO>) {
// <-- an IO thread (the main thread can do other things)
}
// <-- the main thread
}
}
After specifying the context:
fun onButtonClick() {
// <-- the caller thread
mainScope.launch(<http://Dispatchers.IO|Dispatchers.IO>) {
// <-- an IO thread
withContext(<http://Dispatchers.IO|Dispatchers.IO>) {
// <-- the same IO thread
}
// <-- the same IO thread
}
}
Alternatively, when mainScope
is replaced with a custom CoroutineScope(SupervisorJob())
(which implicitly uses Dispatchers.Default
):
fun onButtonClick() {
// <-- the caller thread
customScope.launch {
// <-- an Dispatchers.Default thread
withContext(<http://Dispatchers.IO|Dispatchers.IO>) {
// <-- the same thread, but running IO tasks
}
// <-- the same thread
}
}
So, the main thread does not run network calls in any scenario. There is still a difference, though: in the orginal code, the main thread does two extra things: first, scheduling the task for running on the IO thread, and second, processing the result of the network call. Both are very quick, simple operations, but they still get scheduled into the queue (since MainScope()
uses Dispatchers.Main
and not Dispatchers.Main.immediate
), extracted from there, and performed.
Google filed a report that the performance impact of this dispatching behavior is noticeable in practice: https://github.com/Kotlin/kotlinx.coroutines/issues/4040
So, my guess is that your animations are already very close to stuttering, and even the small performance degradation on the main thread causes missing the deadline, causing the stuttering.
You can check if this is indeed the case by doing this:
fun onButtonClick() {
mainScope.launch(<http://Dispatchers.IO|Dispatchers.IO>) {
withContext(Dispatchers.Main) { /* nothing */ }
withContext(<http://Dispatchers.IO|Dispatchers.IO>) {
// network call
}
withContext(Dispatchers.Main) { /* nothing */ }
}
}
If this also lags, this means that even a trivial amount of work on the main thread is enough to cause the lag.
Side note: if any exception happens in this launch
block, it's enough to crash the whole application, since no CoroutineExceptionHandler
is supplied. Please make sure to include one: CoroutineScope(SupervisorJob() + CoroutineExceptionHandler { /* handle the exception appropriately */ })
.svenjacobs
09/11/2025, 1:47 PM