
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
launchfunction returns immediately, and the code inside it runs on a different thread in the background. Thetry-catchblock 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?:
asyncalso returns immediately with aDeferredobject. The exception happens later inside the coroutine and gets stored inside thatDeferredobject, 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-catchInside async can handle exceptions locally and based on the handling, one can return a result from the catch block itself. In this case, when aDeferredobject 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
supervisorScopeoverrides the parent’s cancellation policy. An exception from a direct child of asupervisorScopewill 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
launchin aCoroutineScopethat uses aSupervisorJobor inGlobalScope. - When It Doesn’t Work: It won’t work on children of a regular
coroutineScopebecause the parent gets cancelled and handles the exception itself. It won’t work forasyncbecause the exception is held within theDeferredobject, 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
supervisorScopeensures that if oneasyncoperation fails internally, other sibling coroutines (bothlaunchandasync) keep running. However, the responsibility of handling the failedasyncoperation’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
CancellationExceptionsignals that the coroutine was cancelled as expected, which is a normal part of structured concurrency. While you can catch it in atry-catchblock, 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 thefinallyblock.
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
NonCancellablecontext. 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
Jobbecomes a child of the parent coroutine’sJob. - A parent
Jobhas two key responsibilities:
- It waits for all its children to complete before it can complete.
- 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 thesupervisorScopeblock.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 aTimeoutCancellationExceptionif it doesn’t complete within the specified time. This is a subclass ofCancellationException, so it cancels the coroutine.withTimeoutOrNull(timeMillis) { ... }: This is a non-throwing alternative. If the time limit is exceeded, it stops the coroutine and returnsnullinstead 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
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
asyncjobs 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 likecoroutineScope.
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
This article was previously published on proandroiddev.com.


