Blog Infos
Author
Published
Topics
, , , ,
Published

Designed by catalyststuff on Freepik

At its heart, exception handling in coroutines is all about structured concurrency. Think of it like a family tree. If a child coroutine fails with an exception, it tells its parent. The parent then cancels all its other children and then cancels itself, passing the exception further up the tree. This ensures no coroutine is ever lost or orphaned.

Let’s dive into the different scenarios you’ll encounter.

Case 1: The launch Builder

launch is your “fire-and-forget” coroutine builder. You use it when you don’t need a result back. Its exception behaviour can be a bit tricky at first.

Incorrect: try-catch outside launch

This is a common mistake. Wrapping a launch call in a try-catch block will not catch exceptions that happen inside it.

  • Why?: The launch function returns immediately, and the code inside it runs on a different thread in the background. The try-catch block finishes long before the exception even happens. The exception propagates up to the top-level handler, and your app will crash if it’s not handled.
Example

 

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Before launch")
    try {
        // This launch block will throw an exception
        launch {
            println("Inside launch: Throwing exception...")
            delay(500) // Simulate some work
            throw RuntimeException("Something went wrong!")
        }
    } catch (e: Exception) {
        // ❌ THIS BLOCK WILL NEVER BE REACHED ❌
        println("Caught exception: $e")
    }
    println("After launch")
    delay(1000) // Keep the main coroutine alive to see the crash
    println("Main finished")
}

 

Output:

 

Before launch
After launch
Inside launch: Throwing exception...
Exception in thread "main" java.lang.RuntimeException: Something went wrong!
... (stack trace) ...

 

Notice how “Caught exception” is never printed. The app just crashes.

Correct: try-catch inside launch

To handle an exception specific to a launch coroutine, place the try-catch block directly inside its lambda.

  • Why?: This puts the exception handling logic right where the exception occurs, within the coroutine’s own execution path. This way, you can gracefully handle the error without crashing the entire scope.

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Before launch")
    launch {
        try {
            // The risky code is now inside the try block
            println("Inside launch: Doing some work...")
            delay(500)
            throw RuntimeException("Oops, failed!")
        } catch (e: Exception) {
            // ✅ This block correctly catches the exception
            println("Caught exception inside launch: ${e.message}")
        }
    }
    println("After launch")
    delay(1000) // Wait for the launch to complete
    println("Main finished")
}

Output:

 

Before launch
After launch
Inside launch: Doing some work...
Caught exception inside launch: Oops, failed!
Main finished
... (stack trace) ...

 

Here, the program handles the exception gracefully and doesn’t crash.

Case 2: The async Builder

async is used when you need to perform a task and get a result back later. This result is wrapped in a Deferred object. async handles exceptions differently—it “holds” the exception until you ask for the result.

Incorrect: try-catch around the async call

Just like launch, wrapping the async call itself in a try-catch does nothing.

  • Why?async also returns immediately with a Deferred object. The exception happens later inside the coroutine and gets stored inside that Deferred object, waiting to be revealed.
Correct: try-catch around the .await() call

The exception thrown inside async is only re-thrown when you call .await() to get the result. This is the moment of truth.

  • Why?: This design allows you to decide when and how to handle the potential failure. The exception is part of the “deferred” result.

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Before async")
    val deferredResult: Deferred<String> = async {
        println("Inside async: About to fail...")
        delay(500)
        throw IllegalStateException("Async operation failed!")
        "This will never be returned"
    }
    println("After async")
    try {
        // The exception is thrown here, when we ask for the result
        val result = deferredResult.await()
        println("Result: $result")
    } catch (e: Exception) {
        // ✅ The exception is caught correctly here
        println("Caught exception on await: ${e.message}")
    }
    println("Main finished")
}

Output:

 

Before async
After async
Inside async: About to fail...
Caught exception on await: Async operation failed!
Main finished
Exception in thread "main" java.lang.IllegalStateException: Async operation failed!
... (stack trace) ...
Alternative: try-catch inside async
  • Why?try-catch Inside async can handle exceptions locally and based on the handling, one can return a result from the catch block itself. In this case, when a Deferred object is returned, it will never throw any exception as it was already handled.

 

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Before async")
    val deferredResult: Deferred<String> = async {
        println("Inside async: About to fail...")
        delay(500)
        throw IllegalStateException("Async operation failed!")
        "This will never be returned"
    }
    println("After async")
    try {
        // The exception is thrown here, when we ask for the result
        val result = deferredResult.await()
        println("Result: $result")
    } catch (e: Exception) {
        // ✅ The exception is caught correctly here
        println("Caught exception on await: ${e.message}")
    }
    println("Main finished")
}

 

Output:

 

Before async
After async
Inside async: About to fail...
Caught exception inside async: Async operation failed!
Result: Exception occurred inside aync
Main finished
... (stack trace) ...

 

Case 3: Parent-Child Relationships (coroutineScope)

This is where structured concurrency really shines. A coroutineScope will wait for all its children to complete. If any child fails, the scope immediately cancels all other children and then fails itself.

One Child Fails, All Fail
  • Why?: This prevents work from continuing in an inconsistent state. If one crucial part of an operation fails, it’s often safer to cancel the entire operation.

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Starting the scope...")
    try {
        coroutineScope { // Create a new scope
            launch {
                try {
                    println("Child 1: Working for 1000ms...")
                    delay(1000)
                    println("Child 1: Finished.") // This line will not be reached
                } finally {
                    println("Child 1: I was cancelled!")
                }
            }
            launch {
                println("Child 2: Working for 500ms then failing...")
                delay(500)
                throw RuntimeException("Child 2 failed!")
            }
        }
    } catch (e: Exception) {
        // The exception from Child 2 propagates up to the scope and is caught here
        println("Caught exception in parent scope: ${e.message}")
    }
    println("Scope finished.")
}

Output:

 

Starting the scope...
Child 1: Working for 1000ms...
Child 2: Working for 500ms then failing...
Child 1: I was cancelled!
Caught exception in parent scope: Child 2 failed!
Scope finished.
... (stack trace) ...

 

As you can see, when Child 2 failed, Child 1 was immediately cancelled before it could finish its work.

Nested Scope

If we add another coroutine scope inside a coroutine scope, it will also be cancelled like any other jobs running inside the parent coroutine scope

Case 4: Isolating Failures (supervisorScope)

What if you don’t want one child’s failure to affect its siblings? For that, you use a supervisorScope.

  • Why?: A supervisorScope overrides the parent’s cancellation policy. An exception from a direct child of a supervisorScope will not cancel the scope or its other children. This is perfect for UI applications where independent tasks are running, and one failing shouldn’t freeze the entire screen.

Important Note: The supervision only applies to direct children. If you use a coroutineScope inside a supervisorScope, that inner coroutineScope will still follow its own “cancel-all” rule.

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Starting the supervisor scope...")
    try {
        supervisorScope { // Children failures are isolated
            launch {
                println("Child 1: Working for 500ms then failing...")
                delay(500)
                throw RuntimeException("Child 1 failed!")
            }
            launch {
                try {
                    println("Child 2: Working for 1000ms...")
                    delay(1000)
                    println("Child 2: Finished successfully!") // This line IS reached
                } finally {
                    println("Child 2: I was NOT cancelled!")
                }
            }
        }
    } catch (e: Exception) {
        // This catch block won't be hit because the supervisorScope
        // doesn't propagate the child's exception upwards.
        println("Caught exception in parent scope: $e")
    }
    println("Supervisor scope finished.")
}

Output:

 

Starting the supervisor scope...
Child 1: Working for 500ms then failing...
Child 2: Working for 1000ms...
Exception in thread "main" java.lang.RuntimeException: Child 1 failed! // The exception is still thrown, but uncaught
    ... (stack trace) ...
Child 2: Finished successfully!
Child 2: I was NOT cancelled!
Supervisor scope finished.

 

Wait, the program still crashed! Why? Because the exception from Child 1 wasn’t handled anywhere. The supervisorScope prevents cancellation, but it doesn’t magically swallow the exception. You still need to handle it with the child who fails.

Correct Way to use supervisorScope

You must handle exceptions within the children of a supervisorScope.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Starting the supervisor scope...")
    supervisorScope {
        // Child 1 with its own exception handling
        launch {
            try {
                println("Child 1: I'm going to fail.")
                delay(500)
                throw RuntimeException("Child 1 failed!")
            } catch (e: Exception) {
                println("Caught in Child 1: ${e.message}")
            }
        }
        // Child 2 runs independently
        launch {
            println("Child 2: I will succeed.")
            delay(1000)
            println("Child 2: Finished successfully!")
        }
    }
    println("Supervisor scope finished.")
}

Output:

 

Starting the supervisor scope...
Child 1: I'm going to fail.
Child 2: I will succeed.
Caught in Child 1: Child 1 failed!
Child 2: Finished successfully!
Supervisor scope finished.

 

Now it works perfectly! Child 1 failed and handled its own error, while Child 2 completed its work unaffected.

Case 5: The Global Catcher (CoroutineExceptionHandler)

This is your last line of defense. A CoroutineExceptionHandler is a special context element that you can add to a top-level scope to catch any exceptions that were not handled otherwise.

  • When to Use: It’s primarily for logging, reporting errors, or performing cleanup for uncaught exceptions. It is most effective with launch in a CoroutineScope that uses a SupervisorJob or in GlobalScope.
  • When It Doesn’t Work: It won’t work on children of a regular coroutineScope because the parent gets cancelled and handles the exception itself. It won’t work for async because the exception is held within the Deferred object, waiting for .await().

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    // 1. Create the handler
    val handler = CoroutineExceptionHandler { _, exception ->
        println("Caught by CoroutineExceptionHandler: $exception")
    }
    // 2. Create a scope with a SupervisorJob and the handler
    // A SupervisorJob is like a supervisorScope for a whole scope.
    val scope = CoroutineScope(SupervisorJob() + handler)
    // This launch will fail, and the handler will catch it
    scope.launch {
        println("Child 1: Failing...")
        throw AssertionError("Something is wrong!")
    }
    // This launch will succeed, unaffected by the first one's failure
    scope.launch {
        delay(500)
        println("Child 2: I'm alive!")
    }.join() // wait for child 2 to finish for a clean exit
    delay(1000) // Give time for the handler to run
    println("Main finished.")
}

Output:

 

Child 1: Failing...
Caught by CoroutineExceptionHandler: java.lang.AssertionError: Something is wrong!
Child 2: I'm alive!
Main finished.

 

The handler caught the error, and the second child was not cancelled, all thanks to the SupervisorJob.

Case 6: async Within a supervisorScope

We learned that supervisorScope prevents one failing child from cancelling its siblings. But how does this work with async? The rule for async remains the same: the exception is stored in the Deferred object and only thrown when you call .await().

  • Why it Matters: The supervisorScope ensures that if one async operation fails internally, other sibling coroutines (both launch and async) keep running. However, the responsibility of handling the failed async operation’s exception still lies with the code that calls .await().

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Starting supervisor scope with async...")
    supervisorScope {
        // First async operation, destined to fail
        val deferredFailure = async {
            println("Async 1: I will fail in 500ms.")
            delay(500)
            throw IllegalStateException("Failure!")
        }
        // Second async operation, which will succeed
        val deferredSuccess = async {
            println("Async 2: I will succeed in 1000ms.")
            delay(1000)
            "Success!"
        }
        // Try to await the result of the failing async
        try {
            deferredFailure.await()
        } catch (e: Exception) {
            println("Caught expected failure from Async 1: ${e.message}")
        }
        // Awaiting the successful async works perfectly fine, it was not cancelled
        try {
            val result = deferredSuccess.await()
            println("Result from Async 2: $result")
        } catch (e: Exception) {
            println("Caught unexpected failure from Async 2: $e")
        }
    }
    println("Supervisor scope finished.")
}

Output:

 

Starting supervisor scope with async...
Async 1: I will fail in 500ms.
Async 2: I will succeed in 1000ms.
Caught expected failure from Async 1: Failure!
Result from Async 2: Success!
Supervisor scope finished.

 

This shows that deferredSuccess was not affected by deferredFailure‘s exception, but we still had to handle the exception at the await call site.

Case 7: Cancellation is a Special Kind of Exception

When a coroutine is cancelled, it throws a CancellationException. This is a special exception that is mostly ignored by coroutine machinery.

  • Why it Matters: A CancellationException signals that the coroutine was cancelled as expected, which is a normal part of structured concurrency. While you can catch it in a try-catch block, you generally should not swallow it. If you catch it to perform some action, you should re-throw it so the cancellation process can complete properly. The best place for cleanup logic is the finally block.

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            println("Job: I'm working...")
            delay(2000) // This is a suspension point
            println("Job: I'm done.") // This will never be printed
        } catch (e: CancellationException) {
            // This is okay for logging, but you must re-throw it!
            println("Job: I was cancelled. Re-throwing exception.")
            throw e
        } catch (e: Exception) {
            println("Job: Caught some other exception: $e")
        } finally {
            // ✅ This is the correct place for cleanup logic
            println("Job: Finally block executed for cleanup.")
        }
    }
    delay(1000)
    println("Main: I'm tired of waiting, cancelling the job.")
    job.cancelAndJoin() // Cancel the job and wait for it to finish
    println("Main: Job has been cancelled.")
}

Output:

 

Job: I'm working...
Main: I'm tired of waiting, cancelling the job.
Job: I was cancelled. Re-throwing exception.
Job: Finally block executed for cleanup.
Main: Job has been cancelled.

 

Case 8: Unstoppable Cleanup with NonCancellable

What if your cleanup code in the finally block is also a suspending function (like writing to a file or a network call)? If the coroutine is already cancelled, any new suspending call inside it will immediately throw a CancellationException.

  • Solution: To run a suspending function in a cleanup block, you must switch to a NonCancellable context. This guarantees that the code inside this block will run to completion without being cancelled.

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            println("Job: Working...")
            delay(2000)
        } finally {
            println("Job: Entering finally block.")
            // This suspending call would fail without NonCancellable
            withContext(NonCancellable) {
                println("Job: Starting crucial cleanup that takes 500ms...")
                delay(500) // Simulate a suspending cleanup operation
                println("Job: Crucial cleanup finished.")
            }
        }
    }
    delay(1000)
    println("Main: Cancelling the job.")
    job.cancelAndJoin()
    println("Main: Job cancelled.")
}

Output:

 

Job: Working...
Main: Cancelling the job.
Job: Entering finally block.
Job: Starting crucial cleanup that takes 500ms...
Job: Crucial cleanup finished.
Main: Job cancelled.

 

Even though the job was cancelled, the suspending delay(500) inside the NonCancellable block was allowed to complete.

Case 9: Nested Scopes & Propagation

The rules of coroutineScope (fail-all) and supervisorScope (isolate-failure) become even more interesting when you nest them. The key is to remember that the rules apply to the immediate children of that scope.

coroutineScope inside supervisorScope

An exception inside the inner coroutineScope will cancel its own siblings, but it will not cancel other children of the outer supervisorScope.

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    // Top-level scope that isolates its direct children's failures
    supervisorScope {
        // First child of supervisorScope, this will not be cancelled
        launch {
            delay(1000)
            println("Supervisor's Child 1: I survived!")
        }
        // Second child of supervisorScope, which contains its own strict scope
        coroutineScope {
            launch {
                delay(600)
                // This sibling will be cancelled by the failure below
                println("Inner Scope Sibling: I will be cancelled.")
            }
            launch {
                delay(300)
                println("Inner Scope Failing Child: I'm about to fail!")
                throw RuntimeException("Failure in inner scope")
            }
        }
    }
    println("All done.")
}

Output:

 

Inner Scope Failing Child: I'm about to fail!
Supervisor's Child 1: I survived!
All done.
Exception in thread "main" java.lang.RuntimeException: Failure in inner scope
...

 

Notice “Supervisor’s Child 1” printed its message, proving it was unaffected. But “Inner Scope Sibling” did not, because its sibling inside the coroutineScope failed. The exception still crashes the program because it was never caught.

Case 10: The Job Hierarchy in Detail 🌳

At the core of structured concurrency is the concept of a Job hierarchy. Think of it as a tree of tasks.

  • When you launch a coroutine inside another coroutine (or a scope), the new coroutine’s Job becomes a child of the parent coroutine’s Job.
  • A parent Job has two key responsibilities:
  1. It waits for all its children to complete before it can complete.
  2. If a child fails with an exception (and it’s not a supervisorScope), the parent cancels all other children and then fails itself.

This parent-child link is what makes coroutines “structured” and prevents you from losing track of them.

Example Visualization

import kotlinx.coroutines.*

fun main() = runBlocking { // This creates the root Job (parent)
    println("Parent Scope: I'm the parent job.")
    // Child Job 1
    val job1 = launch {
        println("  Child 1: I'm a child of the parent scope.")
        delay(1000)
        println("  Child 1: I'm done.")
    }
    // Child Job 2
    val job2 = launch {
        println("  Child 2: I'm also a child.")
        delay(500)
        // Let's create a grandchild
        launch {
            println("    Grandchild: My parent is Child 2.")
            delay(500)
            println("    Grandchild: I'm done.")
        }
        println("  Child 2: I'm done.")
    }
    println("Parent Scope: Waiting for my children to finish.")
} // The runBlocking scope will not finish until job1 and job2 are complete.

Output:

 

Parent Scope: I'm the parent job.
Parent Scope: Waiting for my children to finish.
  Child 1: I'm a child of the parent scope.
  Child 2: I'm also a child.
    Grandchild: My parent is Child 2.
  Child 2: I'm done.
    Grandchild: I'm done.
  Child 1: I'm done.

 

Here, runBlocking is the parent. It only finishes after both job1 and job2 (and job2‘s own child) are complete.

Case 11: supervisorScope vs. CoroutineScope(SupervisorJob())

This is a subtle but important architectural distinction.

  • supervisorScope { ... }: You use this builder to create a localized bubble where failures don’t spread. It’s for isolating a group of related tasks. If an exception inside this scope is uncaught, it still propagates outside the supervisorScope block.
  • CoroutineScope(SupervisorJob()): You create a scope object with this. Any coroutines launched directly from this scope object are independent. This pattern is ideal for components with a lifecycle that manage multiple independent, top-level tasks (like a server or an Android ViewModel). A failure in one task will not destroy the scope itself or other tasks.

Example: A ViewModel-like Scope

import kotlinx.coroutines.*

// This class simulates a long-living component like an Android ViewModel or a server.
class Component {
    // Create a scope for this component. Its Job is a SupervisorJob.
    // An uncaught exception will be handled by the handler, but the scope itself remains active.
    private val scope = CoroutineScope(SupervisorJob() + CoroutineExceptionHandler { _, throwable ->
        println("Component Scope: Caught an error: $throwable")
    })
    // This task might fail, but it won't affect other tasks in the scope.
    fun startRiskyTask() {
        scope.launch {
            println("Risky Task: Starting...")
            delay(500)
            throw RuntimeException("Something went wrong!")
        }
    }
    // This task can be started independently and will continue to run.
    fun startStableTask() {
        scope.launch {
            println("Stable Task: I'm running...")
            delay(1500)
            println("Stable Task: I finished successfully.")
        }
    }
    fun destroy() {
        println("Component: Destroying and cancelling scope.")
        scope.cancel() // Cancel all coroutines when the component is destroyed.
    }
}
fun main() = runBlocking {
    val component = Component()
    component.startRiskyTask()
    component.startStableTask()
    delay(2000) // Let the tasks run for a while.
    component.destroy()
    delay(500)
}

Output:

 

Risky Task: Starting...
Stable Task: I'm running...
Component Scope: Caught an error: java.lang.RuntimeException: Something went wrong!
Stable Task: I finished successfully.
Component: Destroying and cancelling scope.

 

Even though the risky task failed, the stable task completed successfully because the scope used a SupervisorJob. The scope itself remained active until destroy() was called.

Case 12: Handling Timeouts ⏰

Long-running operations can be a problem. Coroutines provide a clean way to enforce a time limit.

  • withTimeout(timeMillis) { ... }: This runs a block of code and throws a TimeoutCancellationException if it doesn’t complete within the specified time. This is a subclass of CancellationException, so it cancels the coroutine.
  • withTimeoutOrNull(timeMillis) { ... }: This is a non-throwing alternative. If the time limit is exceeded, it stops the coroutine and returns null instead of throwing an exception. This is often cleaner to handle.

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    // Using withTimeout (throws exception)
    try {
        withTimeout(1000L) {
            println("Task 1: I have 1 second to complete.")
            delay(1500L) // This will take too long
            println("Task 1: I finished.") // This won't be printed
        }
    } catch (e: TimeoutCancellationException) {
        println("Task 1 failed: ${e.message}")
    }
    println("---")
    // Using withTimeoutOrNull (returns null)
    val result = withTimeoutOrNull(1000L) {
        println("Task 2: I also have 1 second.")
        delay(1500L) // Again, too long
        "Success" // This value will not be returned
    }
    if (result == null) {
        println("Task 2 timed out and returned null.")
    } else {
        println("Task 2 finished with result: $result")
    }
}

Output:

 

Task 1: I have 1 second to complete.
Task 1 failed: Timed out waiting for 1000 ms
---
Task 2: I also have 1 second.
Task 2 timed out and returned null.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Case 13: Exceptions When Awaiting Multiple Jobs

What if you start several async jobs and wait for them all? The awaitAll() extension function is perfect for this.

  • Behavior: If any of the async jobs you are awaiting fails, awaitAll() will immediately cancel all the other jobs and re-throw the exception from the one that failed. It follows the “all-or-nothing” principle, just like coroutineScope.

Example

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Starting multiple async jobs.")
    try {
        coroutineScope { // A scope is needed to launch coroutines
            val deferred1 = async {
                delay(1000)
                println("Job 1: Success.")
                "Result 1"
            }
            val deferred2 = async {
                delay(500)
                println("Job 2: Failing!")
                throw IllegalStateException("Job 2 Error")
            }
            val deferred3 = async {
                delay(1200)
                // This will be cancelled before it can complete
                println("Job 3: Success.")
                "Result 3"
            }
            // Wait for all of them to complete
            val results = awaitAll(deferred1, deferred2, deferred3)
            println("All results: $results")
        }
    } catch (e: Exception) {
        println("Caught exception in awaitAll: ${e.message}")
    }
}

Output:

 

Starting multiple async jobs.
Job 2: Failing!
Job 1: Success.
Caught exception in awaitAll: Job 2 Error

 

As you can see, the moment Job 2 failed, awaitAll threw its exception. Job 3, which was still running, was cancelled as a result. Job 1 had already been completed, so its message was printed.

So this is all about exception handling in coroutines. Hope you find this article interesting.
For any questions or queries, let’s connect on LinkedIn

https://www.linkedin.com/in/devbaljeet/

This article was previously published on proandroiddev.com.

Menu