Kotlin Coroutine Exception Handling

Exceptions inside coroutines behave differently than in normal code. A failing child coroutine cancels its siblings and propagates the exception upward. Understanding CoroutineExceptionHandler, SupervisorJob, and supervisorScope lets you build resilient concurrent systems where one failure does not bring everything down.

How Exceptions Propagate


Default behavior:
  Parent scope
  ├── Child A → runs fine
  └── Child B → throws Exception
        ↓
  Exception propagates to Parent
  Parent cancels Child A
  Exception reaches the caller

With SupervisorJob:
  Parent scope (supervisor)
  ├── Child A → runs fine  ← NOT cancelled
  └── Child B → throws Exception
        ↓
  Exception is isolated to Child B only

Basic Exception Handling in Coroutines

import kotlinx.coroutines.*

fun main() = runBlocking {
    try {
        launch {
            delay(100)
            throw RuntimeException("Something went wrong in child")
        }.join()
    } catch (e: RuntimeException) {
        println("Caught in parent: ${e.message}")
    }
    println("Parent continues after catch")
}

CoroutineExceptionHandler

Attach a handler to a scope to receive unhandled exceptions from launch coroutines:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { context, exception ->
        println("Handled: ${exception.message}")
    }

    val scope = CoroutineScope(Dispatchers.Default + handler)

    scope.launch {
        println("Task 1 starting")
        delay(200)
        throw IllegalStateException("Task 1 failed!")
    }

    scope.launch {
        println("Task 2 starting")
        delay(500)
        println("Task 2 done")
    }

    delay(1000)   // wait for both
}

Output:

Task 1 starting
Task 2 starting
Handled: Task 1 failed!

Task 2 is cancelled because the default scope propagates the failure from Task 1.

SupervisorJob — Isolate Failures

import kotlinx.coroutines.*

fun main() = runBlocking {
    val handler = CoroutineExceptionHandler { _, e ->
        println("Caught isolated error: ${e.message}")
    }

    // SupervisorJob prevents one child failure from cancelling siblings
    val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)

    scope.launch {
        delay(100)
        throw RuntimeException("Worker A failed")
    }

    scope.launch {
        delay(300)
        println("Worker B completed successfully")   // NOT cancelled
    }

    delay(600)
}

Output:

Caught isolated error: Worker A failed
Worker B completed successfully

supervisorScope

Use supervisorScope inside a suspend function for the same isolation without creating a new scope explicitly:

import kotlinx.coroutines.*

suspend fun loadDashboard() = supervisorScope {
    val newsDeferred    = async { fetchNews() }
    val weatherDeferred = async { fetchWeather() }
    val adsDeferred     = async { fetchAds() }

    val news    = try { newsDeferred.await() }    catch (e: Exception) { "News unavailable" }
    val weather = try { weatherDeferred.await() } catch (e: Exception) { "Weather unavailable" }
    val ads     = try { adsDeferred.await() }     catch (e: Exception) { "Ads unavailable" }

    println("News: $news")
    println("Weather: $weather")
    println("Ads: $ads")
}

suspend fun fetchNews(): String    { delay(200); return "Top headlines loaded" }
suspend fun fetchWeather(): String { delay(100); throw RuntimeException("API down") }
suspend fun fetchAds(): String     { delay(150); return "3 ads loaded" }

fun main() = runBlocking { loadDashboard() }

Output:

News: Top headlines loaded
Weather: Weather unavailable
Ads: 3 ads loaded

async Exception Handling

import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferred = async {
        delay(100)
        throw RuntimeException("async failed")
        "result"
    }

    // Exception is thrown when you call await(), not when async starts
    val result = try {
        deferred.await()
    } catch (e: RuntimeException) {
        "fallback value"
    }

    println(result)   // fallback value
}

Practical Example: Resilient API Fetcher

import kotlinx.coroutines.*

data class DashboardData(val user: String, val stats: String, val alerts: String)

suspend fun fetchUser(): String   { delay(300); return "Alice" }
suspend fun fetchStats(): String  { delay(200); throw RuntimeException("Stats DB down") }
suspend fun fetchAlerts(): String { delay(150); return "2 alerts" }

fun main() = runBlocking {
    val data = supervisorScope {
        val userD   = async { fetchUser() }
        val statsD  = async { fetchStats() }
        val alertsD = async { fetchAlerts() }

        DashboardData(
            user   = runCatching { userD.await() }.getOrDefault("Unknown User"),
            stats  = runCatching { statsD.await() }.getOrDefault("Stats unavailable"),
            alerts = runCatching { alertsD.await() }.getOrDefault("No alerts")
        )
    }

    println("User   : ${data.user}")
    println("Stats  : ${data.stats}")
    println("Alerts : ${data.alerts}")
}

Output:

User   : Alice
Stats  : Stats unavailable
Alerts : 2 alerts

Leave a Comment

Your email address will not be published. Required fields are marked *