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