Coroutine Exceptions & Cancellation

Reviewed & published by Brayan K

Structured concurrency makes errors and cancellation predictable — but only if you know the rules. Exceptions propagate differently in launch and async, and cancellation is cooperative.

Part of the free Kotlin course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

By the end you'll handle failures with CoroutineExceptionHandler and supervisorScope, and write cancellation-aware code.

What You'll Learn in This Lesson

1️⃣ Exception Propagation: launch vs async

launch propagates exceptions eagerly to its parent; a CoroutineExceptionHandler is the last resort. async defers its exception until you call await(), so you wrap that in try/catch.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // launch PROPAGATES exceptions eagerly to its parent.
    // Handle them with try/catch inside, or a CoroutineExceptionHandler.
    val handler = CoroutineExceptionHandler { _, e ->
        println("Caught in handler: ${e.message}")
    }

    val scope = CoroutineScope(SupervisorJob() + handler)
    scope.launch {
        throw RuntimeException("boom")
    }.join()

    // async DEFERS its exception: it is re-thrown at await().
    val deferred = async { throw IllegalStateException("bad state") }
    try {
        deferred.await()
    } catch (e: IllegalStateException) {
        println("Caught from await: ${e.message}")
    }
}

A handler catches uncaught launch failures; async failures appear at await().

2️⃣ Isolating Failures with supervisorScope

With a regular Job, one failing child cancels its siblings. A SupervisorJob or supervisorScope isolates failures so the rest keep running.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // supervisorScope: one child failing does NOT cancel its siblings.
    supervisorScope {
        val ok = launch {
            delay(50)
            println("Sibling finished successfully")
        }
        val bad = launch {
            // Handle locally so it does not reach the handler.
            try {
                throw RuntimeException("child failed")
            } catch (e: RuntimeException) {
                println("Handled: ${e.message}")
            }
        }
        ok.join(); bad.join()
    }
    println("supervisorScope done")
}

The failing child is handled locally, and its sibling completes unaffected.

3️⃣ Cooperative Cancellation

Cancellation throws a CancellationException at the next suspension point. Cooperate by checking isActive, never swallow that exception, and use withContext(NonCancellable) for essential cleanup.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        try {
            var i = 0
            // Cooperative cancellation: check isActive (or call ensureActive/yield).
            while (isActive) {
                println("working ${i++}")
                delay(100)   // delay() also throws CancellationException when cancelled
            }
        } catch (e: CancellationException) {
            // DON'T swallow it — rethrow so cancellation completes.
            println("cancelled, cleaning up")
            throw e
        } finally {
            // Essential cleanup that must run even during cancellation:
            withContext(NonCancellable) {
                delay(20)
                println("cleanup done")
            }
        }
    }

    delay(250)
    job.cancelAndJoin()
    println("main: job cancelled")
}

Your turn. Replace the TODOs, then run and compare.

// 🎯 YOUR TURN — replace each TODO.
import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch(Dispatchers.Default) {
        // 1) Loop only while the coroutine is still active.
        while (TODO) {                 // 👉 isActive
            // pretend to do work
            delay(50)
        }
    }
    delay(200)

    // 2) Cancel the job and wait for it to finish unwinding.
    job.TODO()                          // 👉 cancelAndJoin()
    println("done")
}

Mini-Challenge: Robust Parallel Loads

Run two async loads in a supervisorScope so one failing doesn't stop the other.

// 🎯 MINI-CHALLENGE: robust parallel loads.
//
// 1. In a supervisorScope, start TWO async loads:
//    - loadA() that succeeds and returns "A"
//    - loadB() that throws RuntimeException("B failed")
// 2. await() each inside its OWN try/catch so B's failure
//    does not stop A.
// 3. Print whichever results succeeded, e.g. "Got: A".
//
// Remember: rethrow CancellationException if you catch a broad type.

import kotlinx.coroutines.*

fun main() = runBlocking {
    // your code here
}

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Errors & Cancellation

ConceptKotlin Syntax
Top-level handlerCoroutineExceptionHandler { _, e -> }
Catch async errortry { d.await() } catch (e) { }
Isolate failuressupervisorScope { ... }
Cooperatewhile (isActive) { ... }
Cancel a jobjob.cancelAndJoin()
Cleanup safelywithContext(NonCancellable) { }

🎉 Lesson Complete!

Practice quiz

When does an exception thrown inside a 'launch' coroutine propagate?

  • Immediately, propagating up to the parent/scope (and can crash if unhandled)
  • It is silently ignored
  • Only when you call join()
  • It is returned as a value

Answer: Immediately, propagating up to the parent/scope (and can crash if unhandled). launch propagates exceptions eagerly to its parent; if nothing handles them they reach the CoroutineExceptionHandler or crash.

When is an exception from an 'async' coroutine actually thrown to you?

  • Immediately when async starts
  • Never
  • When you call await() on its Deferred
  • Only in release builds

Answer: When you call await() on its Deferred. async defers the exception; it is re-thrown at the call to await(), so you wrap await in try/catch.

What is a CoroutineExceptionHandler used for?

  • Catching exceptions from async/await
  • A last-resort handler for uncaught exceptions in launch coroutines
  • Cancelling all coroutines
  • Logging successful results

Answer: A last-resort handler for uncaught exceptions in launch coroutines. It installs a top-level handler for uncaught exceptions in launch-style builders; it does not catch async/await exceptions.

How does a normal Job differ from a SupervisorJob when a child fails?

  • SupervisorJob ignores all exceptions
  • A SupervisorJob is faster
  • They are identical
  • With a normal Job a failing child cancels its siblings; with a SupervisorJob siblings keep running

Answer: With a normal Job a failing child cancels its siblings; with a SupervisorJob siblings keep running. A SupervisorJob isolates failures: one child failing does not cancel the others, unlike a regular Job.

Which builder runs a block where a child's failure does NOT cancel its siblings?

  • supervisorScope { }
  • coroutineScope { }
  • runBlocking { }
  • withTimeout { }

Answer: supervisorScope { }. supervisorScope provides supervisor semantics: children fail independently without taking down their siblings.

How should you handle an expected exception inside a single coroutine?

  • Only with a SupervisorJob
  • A try/catch around the suspending call
  • There is no way to catch it
  • By restarting the app

Answer: A try/catch around the suspending call. A plain try/catch inside the coroutine works for handling exceptions from suspend calls locally.

Which check lets a long loop cooperate with cancellation?

  • Looping forever
  • isCancelled() returning false
  • Thread.interrupted()
  • isActive (or ensureActive() / yield())

Answer: isActive (or ensureActive() / yield()). Cancellation is cooperative; checking isActive, calling ensureActive(), or yield() lets the coroutine stop when cancelled.

Why should you NOT swallow a CancellationException in a catch block?

  • It is never thrown
  • It is a checked exception
  • Because cancellation relies on it propagating; swallowing it breaks structured concurrency
  • It improves performance to keep it

Answer: Because cancellation relies on it propagating; swallowing it breaks structured concurrency. CancellationException is the signal that drives cooperative cancellation; catching and ignoring it prevents the coroutine from actually cancelling.

When cancelled, what do suspending functions do at the next suspension point?

  • Return null
  • Throw a CancellationException so the coroutine can unwind
  • Loop forever
  • Start a new thread

Answer: Throw a CancellationException so the coroutine can unwind. Cancellation surfaces as a CancellationException thrown at the next suspension point, letting the coroutine unwind cleanly.

What is withContext(NonCancellable) used for?

  • To run essential cleanup code that must complete even during cancellation
  • To make a coroutine run faster
  • To disable all exceptions
  • To start a new thread

Answer: To run essential cleanup code that must complete even during cancellation. Wrapping cleanup in withContext(NonCancellable) lets suspend calls in a finally block finish even though the coroutine is being cancelled.

Continue this course

Frequently asked questions

Why do launch and async handle exceptions so differently?

They model different intents. launch is fire-and-forget, so an exception has nowhere to be returned — it propagates immediately up to the parent and, if unhandled, reaches a CoroutineExceptionHandler or crashes. async produces a result via a Deferred, so its exception is stored and re-thrown when you call await(). The practical rule: for launch, catch inside the coroutine or install a CoroutineExceptionHandler; for async, wrap the await() call in try/catch. A handler does not catch async/await exceptions — those surface at await.

What is structured concurrency doing when one child fails?

By default, coroutines use a regular Job: if one child throws, it cancels its parent, which in turn cancels all the sibling coroutines — failure tears down the whole scope. That's often what you want, but sometimes one failure shouldn't doom the rest. A SupervisorJob (or a supervisorScope block) changes the policy so a failing child is isolated and its siblings keep running. You still handle the failing child's exception (locally or via a handler), but it no longer cancels everything around it.

How does cancellation actually work, and why is it cooperative?

Cancelling a coroutine doesn't forcibly kill a thread. Instead, the coroutine is marked cancelled and the next suspension point throws a CancellationException, letting it unwind cleanly. This is 'cooperative': your code must reach a suspension point or check for cancellation for it to take effect. In tight CPU loops there may be no suspension, so you cooperate explicitly by checking isActive, calling ensureActive(), or yield(). Built-in suspend functions like delay() already check cancellation for you.

What should I never do with CancellationException, and how do I run cleanup?

Never swallow a CancellationException. It's the very signal that drives cancellation, so catching it and continuing breaks structured concurrency — the coroutine won't actually cancel. If you catch a broad Exception, re-throw it when it's a CancellationException (or catch more specific types instead). For cleanup, a finally block runs during cancellation, but a cancelled coroutine can't suspend normally — so if your cleanup needs to call suspend functions, wrap it in withContext(NonCancellable) so that essential work (closing resources, flushing logs) completes even as the coroutine is being torn down.