Structured Concurrency in Practice
Coroutines Basics introduced structured concurrency as the idea that every coroutine lives inside a scope, and that scope won't complete until all its children do. That's the theory. This lesson is the practice — the combined patterns you'll actually reach for once TrailLog needs to do more than one thing at once, cleanly.
Loading Two Things in Parallel: coroutineScope
Say TrailLog's trail detail screen needs both the trail's saved details and a live
weather forecast for its location, and there's no reason to fetch one after the other — they're
independent, so they should run at the same time. coroutineScope { } is built exactly
for this: it launches child coroutines and suspends until every one of them finishes, then hands
back control.
Loading trail details and weather concurrently
suspend fun loadTrailScreen(trailId: Int): TrailScreenData = coroutineScope {
val trailDeferred = async { trailRepository.getTrail(trailId) }
val weatherDeferred = async { weatherRepository.getForecast(trailId) }
// both requests are already running concurrently by this point
val trail = trailDeferred.await()
val weather = weatherDeferred.await()
TrailScreenData(trail, weather)
}
Both async calls start immediately — the first line doesn't block the second from
beginning. Calling .await() on each just waits for that particular result, and since
both were already in flight, the total wait is roughly however long the slower of the two takes,
not the sum of both.
coroutineScope vs. supervisorScope: What Happens on Failure
The behavior above hides an important detail: if either child inside a
coroutineScope throws, the whole scope fails — the other child gets cancelled too,
and the exception propagates out. That's often exactly right (if the trail itself fails to load,
there's nothing to show weather for anyway), but not always.
Think about the same screen again: if the weather forecast request fails — the weather service is
down, say — you'd still want the trail's own name, notes, and photo to display normally. Losing
the whole screen because of a non-essential weather call is a poor trade. That's what
supervisorScope { } is for: a failure in one child does not cancel
its siblings.
Letting trail details succeed even if weather fails
suspend fun loadTrailScreen(trailId: Int): TrailScreenData = supervisorScope {
val trailDeferred = async { trailRepository.getTrail(trailId) }
val weatherDeferred = async {
try {
weatherRepository.getForecast(trailId)
} catch (e: Exception) {
null // weather is optional — fall back to "unavailable" instead of failing the screen
}
}
TrailScreenData(trail = trailDeferred.await(), weather = weatherDeferred.await())
}
Notice the try/catch is still needed inside the async block
itself — supervisorScope stops one child's failure from cancelling its siblings, but
it doesn't silently swallow that child's own exception for you. You still decide, per child, what
a failure there should mean.
Cancellation Is Cooperative
A coroutine doesn't get forcibly interrupted when it's cancelled — it has to check
for cancellation and stop itself. Every suspend function from Kotlin's coroutine library
(delay(), await(), most Room and Retrofit calls) already does this check
for you automatically. The bug worth flagging is what happens when you write a tight loop that
never calls a suspend function inside it:
A real, common bug: a loop that ignores cancellation
suspend fun recalculateAllDistances(trails: List<Trail>) {
for (trail in trails) {
// heavy CPU work, no suspend call inside the loop body
recomputeDistance(trail)
}
}
If the scope running this gets cancelled midway through a large list, the loop above keeps
grinding through every remaining trail anyway — cancellation is requested, but nothing inside the
loop ever checks for it. The fix is to give the loop a cooperation point, either by calling
yield() or by checking isActive directly:
suspend fun recalculateAllDistances(trails: List<Trail>) {
for (trail in trails) {
if (!coroutineContext.isActive) return // stop cooperatively once cancelled
recomputeDistance(trail)
}
}
Job and SupervisorJob, Briefly
Every coroutine has a Job attached — it's the handle representing that coroutine's
lifecycle and its parent/child relationships. A regular Job propagates failure and
cancellation from a failed child up to its parent and across to its siblings, which is exactly
what powers coroutineScope's all-or-nothing behavior. A SupervisorJob
deliberately breaks that propagation for failures, which is what lets supervisorScope
(and scopes like viewModelScope, which uses one internally) keep unrelated children
running after one of them fails.
Catching Uncaught Exceptions: CoroutineExceptionHandler
A top-level launch (as opposed to async) has nowhere to report an
exception through a return value — if it throws and nothing catches it, that exception would
otherwise crash the app. A CoroutineExceptionHandler gives you one last place to
intercept it:
Catching an unexpected failure from a background sync launch
val handler = CoroutineExceptionHandler { _, throwable ->
Log.e("TrailSync", "Unexpected sync failure", throwable)
// show a non-blocking error state instead of letting the app crash
}
viewModelScope.launch(handler) {
trailRepository.syncUnsyncedTrails()
}
This is a safety net, not a substitute for handling expected failures with ordinary
try/catch close to where they happen — reserve
CoroutineExceptionHandler for the genuinely unexpected case you didn't already plan
for.