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.

Note: These are correct, working coroutine patterns, but this website's own project doesn't run a live Android Studio build, so nothing here actually executes on this page.
Up next: Coroutines and WorkManager both handle background work triggered from within your app's own components. A Service is different — a component built to keep running in the background with no UI screen behind it at all, like actively tracking a hike's GPS route.