Skip to Content
Course content

37: Exception Handling in Coroutines

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

If you've spent any time with standard Java or Kotlin, your instinct is to wrap risky code in a try-catch block and call it a day. In a synchronous world, that works. But coroutines introduce a hierarchy, and that's where things get messy. I've seen plenty of engineers spend hours wondering why their app is crashing even though they "wrapped everything in a try-catch." The problem is that exceptions in coroutines don't just bubble up the call stack; they propagate through the job hierarchy.

The trap of the cascading failure

Imagine you're building a dashboard that fetches two independent pieces of data: the current weather and the latest news headlines. You might be tempted to fire these off in parallel using async within a launch block. Here is the naive way most people start:

scope.launch {
    try {
        val weather = async { fetchWeather() } // Assume this succeeds
        val news = async { fetchNews() }       // Assume this throws an IOException
        
        updateUI(weather.await(), news.await())
    } catch (e: Exception) {
        println("Caught it! ${e.message}")
    }
}

On the surface, this looks safe. But here is the catch: async behaves differently depending on where it's called. When fetchNews throws an exception, it doesn't just wait for you to call await() to throw that error. It immediately notifies its parent. Because the parent is a standard Job (which is what launch creates), it triggers a chain reaction. The parent cancels itself and, in doing so, cancels all its other children—including your perfectly healthy weather request.

Your try-catch might catch the final result, but the damage is already done. You've killed the entire operation because one non-essential network call failed. In a real app, this means a single failed "Suggested Friends" API call could wipe out the entire user profile screen.

Isolating failures with SupervisorScope

If we want the weather to load even if the news fails, we need to break that propagation chain. We do this by changing the relationship between the parent and the children. Instead of a standard Job, we use a SupervisorJob or, more commonly for local blocks of code, a supervisorScope.

I prefer supervisorScope because it limits the "supervision" to a specific block of code rather than the entire lifecycle of the application scope. Here is how I'd actually write that dashboard logic:

scope.launch {
    supervisorScope {
        val weatherDeferred = async { fetchWeather() }
        val newsDeferred = async { fetchNews() }

        val weather = try {
            weatherDeferred.await()
        } catch (e: Exception) {
            Weather.Empty // Fallback value
        }

        val news = try {
            newsDeferred.await()
        } catch (e: Exception) {
            News.Empty // Fallback value
        }

        updateUI(weather, news)
    }
}

Notice the shift in strategy. By wrapping the async calls in a supervisorScope, a failure in fetchNews no longer signals the parent to kill everything. It stays isolated. Now, the try-catch around the await() call actually means something—it allows us to handle the failure of a specific task and provide a fallback without sacrificing the rest of the UI.

The cost of silence

There is a trade-off here that you need to be aware of. When you use a SupervisorJob, you are essentially telling Kotlin, "I'll take responsibility for the errors." Because the exception doesn't propagate upward, if you forget to wrap your await() in a try-catch, the exception will still be thrown when you try to access the result, but the parent scope will remain alive.

If you use a standard Job, the failure is loud and immediate. If you use a SupervisorJob, the failure is quiet until you touch the result. As a rule of thumb: use standard jobs when the tasks are interdependent (if A fails, B is useless). Use supervisorScope when you're dealing with a collection of independent tasks where a partial success is better than a total failure.




📋 Practical Task

Fixing the Fragmented Dashboard

You are maintaining a sports app. The main screen launches three concurrent requests: fetchScores(), fetchPlayerStats(), and fetchAdBanner(). Currently, the app is using a standard CoroutineScope with launch. Whenever the fetchAdBanner() call fails (which happens often due to ad-blockers), the entire screen goes blank because the scores and stats requests are being cancelled.

Your Task: Rewrite the following code snippet to ensure that fetchScores() and fetchPlayerStats() can still complete and update the UI even if fetchAdBanner() throws an exception. You must use supervisorScope and implement a fallback for the banner (e.g., a null or empty banner) so the app doesn't crash during await().

// CURRENT BROKEN CODE
fun loadDashboard() {
    scope.launch {
        try {
            val scores = async { api.fetchScores() }
            val stats = async { api.fetchPlayerStats() }
            val banner = async { api.fetchAdBanner() }

            updateUI(scores.await(), stats.await(), banner.await())
        } catch (e: Exception) {
            showErrorMessage("Dashboard failed to load")
        }
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.