Coroutines, scopes and cancellation
The Kotlin course taught the three dispatchers and the rule that goes with them: UI on Main, waiting on IO, computing on Default. That rule leaves the Android questions open. Which thread does a Retrofit call actually run on? Does Mandi Bhav need a single withContext? What happens to a download when the farmer presses Home halfway through, or when a screen that started it goes away? This lesson answers each one by logging it on the emulator, then shows the two cancellation bugs that turn up in real Android code, both measured, and the one scope Mandi Bhav adds because of them.
Where a refresh runs
A scratch build logged Thread.currentThread().name at each step of a first download: in PricesViewModel.refresh(), at the top of the repository's download, in an OkHttp interceptor, after the response was parsed, and when refresh() returned.
D/M9 (23075): refresh() called on main
D/M9 (23075): download() on DefaultDispatcher-worker-1
D/M9 (23075): request /api/v1/prices.json on OkHttp http://10.0.2.2:8765/...
D/M9 (23075): request /api/v1/history.json on OkHttp http://10.0.2.2:8765/...
D/M9 (23075): parsed 9 prices, now on DefaultDispatcher-worker-3
D/M9 (23075): refresh() returned 9 on main
| Step | Thread | Who chose it |
|---|---|---|
The tap, the ViewModel |
main |
viewModelScope, which uses Dispatchers.Main.immediate |
| The download's own code | DefaultDispatcher-worker-… |
The app's scope, below |
| The HTTP request, and parsing the JSON | OkHttp … |
Retrofit: a suspend function hands the call to OkHttp's own threads and suspends until it answers |
| The database write | One of Room's threads | Room, as module 8's crash trace showed (LimitedDispatcher$Worker) |
Back in the ViewModel |
main |
The coroutine resumes where it was launched |
Nobody in that table wrote withContext. Retrofit's suspend functions and Room's DAO functions are main-safe: you can call them from the main thread, because they move their own blocking work elsewhere and suspend until it is done. That is the contract every suspend function in the data layer should keep, and it is why the Kotlin course said to put the switch inside the function, not in every caller.
So Mandi Bhav has no withContext at all, and that is correct rather than lucky. You need one when you call something that blocks the thread it is called on:
| Work | Blocks? | Where it belongs |
|---|---|---|
api.latestPrices() (suspend), any Room DAO call |
No; main-safe | Anywhere, including viewModelScope |
call.execute(), File.readText(), BitmapFactory.decodeFile(), an old SDK's synchronous call |
Yes | withContext(Dispatchers.IO) { … } |
| Sorting or filtering thousands of items, parsing a large file yourself | Uses CPU | withContext(Dispatchers.Default) { … } |
MutableStateFlow.update |
No | Any thread; StateFlow is thread-safe |
When a class does need a dispatcher, take it as a constructor parameter, class X @Inject constructor(@IoDispatcher private val io: CoroutineDispatcher), rather than writing Dispatchers.IO inside it, so a test can pass a test dispatcher and control time. Google's coroutine guidance calls this out first. Mandi Bhav injects the one thing it does need: a scope.
When a coroutine is cancelled
viewModelScope is cancelled when its ViewModel is cleared: when the back stack entry it belongs to is popped, as on Back from the detail screen, or when the activity finishes for good. Not on rotation; not when the app goes to the background. LaunchedEffect and rememberCoroutineScope are cancelled sooner, when their composable leaves the screen, which on a tab switch is every time.
| Scope | Cancelled when | Use it for |
|---|---|---|
LaunchedEffect, rememberCoroutineScope |
The composable leaves the composition | UI work: a snackbar, a scroll, an animation |
repeatOnLifecycle(STARTED), collectAsStateWithLifecycle |
The screen stops (Home, another app); restarted when it starts again | Watching something only while it is visible |
viewModelScope |
The ViewModel is cleared |
Work for this screen that should survive rotation |
| An application scope (below) | Never, until the process dies | Work that must finish even if the screen goes |
GlobalScope |
Never, and you cannot replace it in a test | Nothing |
| WorkManager (module 10) | Survives the process dying | Work that must happen even if the app is closed |
Bug one: work that carries on after the user has gone
A reasonable feature request: "refresh every ten seconds while the screen is open, so the prices are always current". The obvious code, in a scratch copy of PricesViewModel:
// The trap: a loop in viewModelScope polls for as long as the ViewModel lives.
viewModelScope.launch {
while (true) {
try {
refreshQuietly("poll")
} catch (e: Exception) {
android.util.Log.d("M9", "poll: failed, $e")
}
kotlinx.coroutines.delay(10_000)
}
}
Open the app, wait twelve seconds, and press Home, at 22:56:39 by the computer's clock:
11-08 22:56:27.535 I/OkHttp (24110): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:56:37.786 I/OkHttp (24110): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:56:47.828 I/OkHttp (24110): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:56:58.103 I/OkHttp (24110): <-- HTTP FAILED: java.net.SocketTimeoutException: failed to connect to /10.0.2.2 (port 8765) from /10.0.2.16 (port 48308) after 10000ms. http://10.0.2.2:8765/api/v1/prices.json (10273ms)
11-08 22:57:08.124 I/OkHttp (24110): --> GET http://10.0.2.2:8765/api/v1/prices.json
11-08 22:57:18.149 I/OkHttp (24110): <-- HTTP FAILED: java.net.SocketTimeoutException: failed to connect to /10.0.2.2 (port 8765) from /10.0.2.16 (port 33602) after 10000ms. http://10.0.2.2:8765/api/v1/prices.json (10024ms)
The ViewModel is not cleared when the user presses Home, so the loop went on asking for prices nobody could see, every ten seconds, and in the background each attempt hung for ten seconds before failing to connect. On a farmer's phone that is mobile data and battery spent on nothing, and Android works hard to stop it, which is module 10's subject. A ViewModel outlives the screen being visible; work that is only useful while the user is looking must stop when the screen stops.
The fix is to tie the work to what the screen collects. Mandi Bhav's one piece of "while you are looking" work, lesson 6's sync when the connection returns, is part of uiState's flow, and uiState uses WhileSubscribed(5_000) from module 5: five seconds after the screen stops collecting, the upstream stops, and the connection callback is unregistered with it. A test pins it down, nobodyWatchingNobodySyncs: the connection comes back while nothing collects the state, and the repository's refresh count stays at 1.
Bug two: a catch that swallows the cancellation
Tie the poll to the lifecycle instead, in the route, and keep the same habit of catching everything:
// The trap: catch (e: Exception) also catches the cancellation.
LaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
var caught = 0
while (true) {
try {
kotlinx.coroutines.delay(10_000)
viewModel.refreshQuietly("visible poll")
} catch (e: Exception) {
caught++
if (caught <= 3 || caught % 100_000 == 0) android.util.Log.d("M9", "visible poll: caught #$caught $e")
}
}
}
}
repeatOnLifecycle cancels the block when the screen stops. Press Home four seconds after opening:
11-08 22:57:32.041 D/M9 (24180): visible poll: caught #1 kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@334a15
11-08 22:57:32.041 D/M9 (24180): visible poll: caught #2 kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@334a15
11-08 22:57:32.041 D/M9 (24180): visible poll: caught #3 kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@334a15
11-08 22:57:32.778 D/M9 (24180): visible poll: caught #100000 kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@334a15
11-08 22:57:34.668 D/M9 (24180): visible poll: caught #400000 kotlinx.coroutines.JobCancellationException: StandaloneCoroutine was cancelled; job=StandaloneCoroutine{Cancelling}@334a15
and top at the same moment:
24180 u0_a237 20 0 16G 199M 98M R 123 10.0 0:05.35 com.riztechacademy.mandibhav.verify
Cancellation in coroutines is an exception, CancellationException, thrown from the next suspension point. catch (e: Exception) caught it, so the coroutine never finished being cancelled; the loop went round, delay saw the coroutine was cancelled and threw again at once, and the loop caught it again. About 160,000 times a second, on the main thread, at 123% CPU, with the app in the background. runCatching { } does the same thing, because it catches Throwable.
The rules that follow:
- Catch the exceptions you mean. Mandi Bhav's
ViewModelcatchesRefreshExceptionand nothing else: lesson 4's data layer turns every network failure into one, and aCancellationExceptionis not one. - If you must catch broadly, at a boundary such as a logging wrapper, rethrow cancellation first:
catch (e: CancellationException) { throw e }above the generalcatch, or callcurrentCoroutineContext().ensureActive()inside it. while (isActive)instead ofwhile (true)would have stopped this loop too. It does not fix a swallowed cancellation elsewhere; catching the right type does.
The network layer has a test for its side of this, cancellingTheCallerIsNotAFailure: a slow response, the caller cancelled after 200 ms, and what comes out is a CancellationException, not a RefreshException saying "timeout".
Work that should finish anyway
The opposite case matters too. A first download takes several seconds on weak data. If the screen that asked for it goes away halfway, the bytes already spent should not be thrown away, and the prices should still be saved for next time. Work like that should not live in the screen's scope. Mandi Bhav adds one application-wide scope, in di/CoroutinesModule.kt:
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ApplicationScope
@Provides
@Singleton
@ApplicationScope
fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
SupervisorJob so that one failed download does not cancel the scope and everything else in it; Dispatchers.Default so that anything started there is off the main thread. The repository starts each download in it, and lets callers wait for the result:
/** The refresh that is running now, if any; a second caller waits for it. */
private var running: Deferred<Int>? = null
override suspend fun refresh(): Int {
val download = synchronized(this) {
running?.takeIf { it.isActive } ?: externalScope.async { download() }.also { running = it }
}
return download.await()
}
Two things happen here. If the caller is cancelled, only its await() is cancelled; the async in the application scope carries on and saves. And if a refresh is already running, a second caller waits for the same one: a tap on "Refresh" while lesson 6's reconnect sync is already downloading costs one download, not two. Two repository tests check exactly that, with a test scope standing in for the application's: aDownloadFinishesEvenIfTheScreenThatAskedIsGone cancels the caller a second into a two-second download and still finds the 7 November prices saved, and twoRefreshesAtOnceShareOneDownload counts one network call for two refreshes.
What it costs: work in this scope is never cancelled for you, so it must be short and bounded (one download, with lesson 4's timeouts), and it must not touch any screen. An application scope is for finishing what was started, not for starting things nobody asked for. It also dies with the process, which is why module 10 uses WorkManager for the daily refresh instead.
Check your work
Which thread a Retrofit suspend call does its network work on. One of OkHttp's own threads; the caller only suspends.
Why Mandi Bhav has no withContext. Retrofit's suspend functions and Room's DAOs are main-safe; nothing it calls blocks.
When to write withContext(Dispatchers.IO). Around a call that blocks the thread: execute(), file reads, old synchronous APIs.
Why the poll kept going after Home. It ran in viewModelScope, and the ViewModel is not cleared when the app goes to the background.
Why the second poll spun at 123% CPU. catch (e: Exception) caught the CancellationException; delay threw it again at once, round and round.
What the application scope gives the refresh. It finishes and saves even if the caller is cancelled, and two callers share one download.
Practice 4. On the emulator, with the server waiting five seconds: back on the Prices tab eight seconds later, the snackbar said "Prices are up to date." and the label "Updated today at 11:18 PM". Switching tabs took the Prices screen out of the composition, but not its ViewModel, which belongs to the tab's back stack; the refresh ran on in viewModelScope, and its message waited in the UI state, module 7's pattern, until a screen was there to show it. Had the refresh been launched from rememberCoroutineScope in the screen, it would have been cancelled at the tab switch, and only the application scope would have saved the download.
Practice
- Add
CoroutinesModuleand the single-flightrefresh(), and pass@ApplicationScopetoRoomPriceRepository. Run the repository tests. - In a scratch copy, log the thread name at each step of a refresh as above, and match your log to the table.
- Build the
viewModelScopepoll, press Home, and watch the requests in logcat with the tagOkHttp. Then build therepeatOnLifecycleversion withcatch (e: Exception), press Home, and watchtop. Force-stop the app afterwards. - With the server told to wait five seconds, tap "Refresh" and switch to the Markets tab at once. Predict the snackbar and the prices you see when you come back, then check.
- Fix the spinning poll three ways: rethrow
CancellationException, callensureActive(), and catch onlyRefreshException. Confirm each stops after Home. - Harder: write a function
withTimeoutOrSaved(block)that returns the saved prices if a refresh has not finished in three seconds, without cancelling the refresh. Decide which scope each part runs in, and write the test with virtual time.
Official documentation
- Best practices for coroutines in Android — Injecting dispatchers, main-safe suspend functions, and an external scope for work that should outlive a screen.
- Use Kotlin coroutines with lifecycle-aware components —
viewModelScope,lifecycleScopeandrepeatOnLifecycle. - Cancellation and timeouts — Cooperative cancellation, and why catching
CancellationExceptionbreaks it. - Coroutine exceptions handling — How exceptions travel through
launch,asyncand supervisors.
Next: the main thread itself, what freezes it, and how Android tells you before your users do.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship