skip to content

How does cancellation propagate through a Job hierarchy when you cancel a parent?

level: seniorimportance: must knowfreq 60%

answer

  1. cancel() flows DOWN the whole subtree
  2. Cooperative: stops at next suspension point
  3. CancellationException is swallowed (not a failure)
  4. Never swallow CancellationException — re-throw
  5. finally + NonCancellable for cleanup

basics

~10 s

Cancelling a parent cancels all of its children, and their children, all the way down the tree. Each coroutine stops at its next suspension point by getting a cancellation exception.

solid answer

~40 s

Calling cancel() on a parent Job transitions it to Cancelling and recursively cancels every descendant by propagating a CancellationException down the parent-child links. Each affected coroutine becomes cooperatively cancelled: at its next suspension point (delay, yield, or any suspend call that checks isActive/ensureActive), it throws CancellationException and unwinds, running finally blocks and NonCancellable cleanup. A parent will not reach Cancelled until all descendants are cancelled and done — cancellation is also structured. CancellationException is special: it's swallowed by the machinery and does not mark the parent as failed. Cancellation flows strictly downward from cancel(); upward propagation happens only on child failure (a different mechanism), and a child cancelling itself does NOT cancel its parent.

code

kotlin · 12 lines
kotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
    val parent = launch {
        launch { try { delay(5000) } finally { println("child A cleanup") } }
        launch { try { delay(5000) } finally { println("child B cleanup") } }
    }
    delay(100)
    parent.cancelAndJoin()        // cancels parent -> both children
    println("all cancelled")
}
// child A cleanup / child B cleanup (order may vary) then: all cancelled

go deeper

for a junior

Knows cancelling a parent stops its children too.

for a middle

Explains cooperative cancellation at suspension points and the role of finally for cleanup.

for a senior

Distinguishes downward cancel() from upward failure propagation, handles CancellationException correctly, and uses NonCancellable.

for a principal

Reasons about cancellation as a structured, awaited subtree teardown and the invariants that prevent leaked or zombie coroutines.

## Cancellation goes down the tree When you call `job.cancel()` (or a coroutine throws, or its scope is cancelled), the `Job`: 1. Moves to the **Cancelling** state. 2. **Recursively cancels all children**, which cancel their children, and so on — the whole subtree. 3. Waits for every descendant to finish unwinding, then becomes **Cancelled**. The mechanism is a `CancellationException` pushed into each coroutine. ## Cancellation is cooperative A coroutine is not killed mid-instruction. It stops at the **next suspension point**: - All `kotlinx.coroutines` `suspend` functions (`delay`, `yield`, `await`, `join`, channel ops...) check for cancellation and throw `CancellationException`. - Pure CPU loops with no suspension keep running. You make them cooperative with `yield()`, `ensureActive()`, or by checking `isActive`. ```kotlin val job = scope.launch { try { while (isActive) { // cooperative check heavyStep() ensureActive() // throws if cancelled } } finally { // cleanup still runs on cancellation withContext(NonCancellable) { closeResource() } } } job.cancel() job.join() ``` ## CancellationException is special - It is the signal used for normal cancellation, so the framework **swallows** it — a cancelled child does **not** mark its parent as failed. - Therefore `try/catch (e: Exception)` that swallows `CancellationException` is a bug: it breaks cancellation. Re-throw it (or catch only your own exceptions). ## Direction matters - **`cancel()` propagates DOWN only.** Cancelling a parent cancels children; cancelling a single child does **not** cancel the parent or siblings. - **Failure propagates UP.** If a child throws a *non*-CancellationException, that failure goes up to the parent, which then cancels the parent's other children — a separate mechanism from `cancel()` (and stoppable with a `SupervisorJob`, covered in the sibling topic). ## Structured cancellation Just like completion, cancellation is structured: the parent's `Cancelling -> Cancelled` transition waits for all descendants to finish their unwinding (including `finally`/`NonCancellable` cleanup). So `parent.cancelAndJoin()` guarantees the whole subtree is fully torn down.

  • If a child catches and swallows CancellationException, what breaks?
    The child appears to keep running and the cancellation/join semantics break — the parent may hang waiting, and the coroutine is no longer cancellable. Always re-throw CancellationException.
  • Does cancelling one child cancel its siblings?
    No. cancel() only propagates downward. Sibling cancellation happens when a child fails (non-CancellationException), which cancels the parent and thus the siblings.
  • How do you run cleanup that itself suspends, during cancellation?
    Wrap it in withContext(NonCancellable) { ... } inside a finally block; otherwise suspending calls in cleanup immediately throw because the job is already cancelling.

Cancelling a parent is like a manager telling their whole team to stop; each person finishes the line they're on, tidies their desk (finally), then leaves.

saying these in an interview costs you the question

  • Thinking cancellation force-kills a coroutine immediately
  • Swallowing CancellationException in a generic catch block
  • Believing a tight CPU loop is cancellable without yield/ensureActive
  • Saying cancel() propagates upward to the parent
  • Forgetting that cleanup needs NonCancellable when it suspends

context