Skip to Content
Course content

181: Structured Concurrency Pitfalls and How to Avoid Them

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By now, you're comfortable launching coroutines and using async to get things done in parallel. But structured concurrency isn't just about keeping things tidy; it's about how failures propagate. If you don't understand the hierarchy, you'll find yourself staring at a screen wondering why a minor network glitch in a background task just killed your entire user session.

Let's build a UserDashboardService. The goal is to fetch three things: user profile data, a list of recent orders, and a notification count. The profile and orders are critical, but the notification count is "nice to have." If notifications fail, we still want to show the profile and orders.

Gathering user data in parallel

I'll start by wrapping these calls in a coroutineScope. This ensures that the aggregateDashboardData function doesn't return until all the child jobs are finished. It's the standard way to group concurrent work.

suspend fun aggregateDashboardData(): DashboardData = coroutineScope {
    val profile = async { fetchUserProfile() }
    val orders = async { fetchRecentOrders() }
    val notifications = async { fetchNotificationCount() }

    DashboardData(profile.await(), orders.await(), notifications.await())
}

This looks clean, right? It's concise and it's parallel. But here is where I usually make my first mistake when I'm rushing a feature.

When one failure takes down the whole ship

Imagine fetchNotificationCount() throws an IOException because the notification microservice is down. In the code above, I'm using coroutineScope. In Kotlin, coroutineScope is "fail-fast." If any child fails, the scope immediately cancels all other children and throws the exception up to the caller.

I tested this and realized that even though fetchUserProfile() had already succeeded, the failure of the notification call cancelled the entire operation. My user sees an error page instead of their profile and orders just because a tiny notification badge failed to load. That's a terrible user experience.

Isolating the blast radius with supervisorScope

To fix this, I need to change the relationship between the parent and the children. I want a "supervisor" relationship where a failure in one child doesn't automatically kill the others. I'll swap coroutineScope for supervisorScope.

But there's a catch: async inside a supervisorScope doesn't propagate the exception to the parent immediately; it encapsulates it in the Deferred object. I have to handle the exception during the await() call.

suspend fun aggregateDashboardData(): DashboardData = supervisorScope {
    val profile = async { fetchUserProfile() }
    val orders = async { fetchRecentOrders() }
    val notifications = async { 
        try { 
            fetchNotificationCount() 
        } catch (e: Exception) { 
            0 // Fallback to zero notifications on error
        } 
    }

    DashboardData(
        profile = profile.await(), 
        orders = orders.await(), 
        notifications = notifications.await()
    )
}

Now, if fetchNotificationCount blows up, it only affects its own async block. The profile and orders calls are left untouched. I've isolated the failure to the specific part of the UI that can actually handle it.

Avoiding the GlobalScope escape hatch

When developers run into these cancellation issues, they're often tempted to just use GlobalScope.launch { ... } to "fire and forget" the problematic task. I've seen this in too many code reviews.

Don't do it. Using GlobalScope breaks the entire contract of structured concurrency. You lose the ability to cancel the operation when the user leaves the screen, and you create memory leaks that are a nightmare to debug in production. If you need a task to outlive the current scope, inject a custom CoroutineScope tied to the application lifecycle (like a ViewModelScope or a custom ServiceScope), but never reach for GlobalScope just to avoid a CancellationException.




📋 Practical Task

Fixing the Fragile User Dashboard Aggregator

You have been handed a piece of code for a WeatherDashboard that fetches data from three sources: currentTemperature, windSpeed, and localNews. Currently, the code uses coroutineScope, meaning if the localNews API (which is flaky) fails, the user doesn't even get to see the temperature.

Your Task: Modify the following function so that:

  1. The failure of fetchLocalNews() does not cancel the temperature or wind speed requests.
  2. If fetchLocalNews() fails, it returns a default string: "News currently unavailable".
  3. The overall function still waits for all three requests to complete (or fail) before returning.
suspend fun getWeatherData(): WeatherData = coroutineScope {
    val temp = async { fetchTemperature() }
    val wind = async { fetchWindSpeed() }
    val news = async { fetchLocalNews() }

    WeatherData(temp.await(), wind.await(), news.await())
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.