skip to content

In Kotlin coroutines, what actually happens when you call Job.cancel()? Does the coroutine stop immediately?

level: juniorimportance: must knowfreq 75%

answer

  1. cancel() = mark, not kill
  2. stops at next suspension point
  3. CPU loop ignores cancel
  4. CancellationException is normal, not a failure
  5. cancelAndJoin to wait

basics

~10 s

No. cancel() just marks the job as cancelled. The coroutine actually stops the next time it pauses at a suspending call. If it never pauses, it keeps running.

solid answer

~40 s

Job.cancel() does not forcibly kill the coroutine. It transitions the Job to a Cancelling state and arranges for a CancellationException to be thrown at the next suspension point. Built-in suspend functions like delay(), yield(), or anything that calls suspendCancellableCoroutine check the cancelled state on resume and throw. So cancellation is cooperative: the coroutine only stops when it next suspends or explicitly checks. A tight CPU loop with no suspending calls will ignore cancel() entirely and run to completion. cancel() also propagates: it cancels children, and the CancellationException it throws is treated as normal cancellation rather than a failure, so it doesn't crash the parent scope.

code

kotlin · 9 lines
kotlin
val job = launch {
    repeat(1000) { i ->
        println("tick $i")
        delay(100) // cancellation observed here
    }
}
delay(250)
job.cancelAndJoin()
println("cancelled")

go deeper

for a junior

Knows cancel() requests cancellation and the coroutine stops at the next delay/suspend, not instantly.

for a middle

Explains suspension points concretely and why a non-suspending loop ignores cancel(); mentions cancelAndJoin().

for a senior

Articulates CancellationException's special non-failure semantics and parent-to-child propagation precisely.

for a principal

Frames cooperative cancellation as a deliberate design choice (structured concurrency, no unsafe forced termination) and its implications for library design.

## The core idea: cancellation is cooperative Kotlin coroutines do **not** use thread interruption or forced termination. When you call `Job.cancel()` (or `job.cancel(cause)`), the runtime simply **flags** the `Job` as cancelled and moves it into a `Cancelling` state. Nothing is killed at that instant. The coroutine continues running its current block of code. The coroutine actually stops only when it reaches a **suspension point** — a place where it calls a `suspend` function that can yield control back to the dispatcher. ## What is a suspension point? A suspension point is any call to a cancellable suspending function. Common ones from the standard library: - `delay(...)` - `yield()` - `withContext(...)` - anything built on `suspendCancellableCoroutine { ... }` When the coroutine resumes at such a point, the machinery checks whether the `Job` was cancelled. If it was, it throws a `CancellationException` instead of continuing. ```kotlin val job = launch { repeat(1000) { i -> println("working $i") delay(100) // <-- suspension point: cancellation is observed here } } delay(250) job.cancel() // marks cancelled; coroutine throws at next delay() job.join() // wait for it to actually finish ``` ## Why a CPU loop ignores cancellation If the coroutine body never suspends, there is no point at which the cancelled flag gets checked, so `cancel()` has no effect until the work finishes: ```kotlin val job = launch(Dispatchers.Default) { var x = 0L while (x < 5_000_000_000L) { x++ } // no suspension -> cancel() is ignored println("done $x") } delay(100) job.cancel() // job keeps running to completion anyway ``` To make such loops cooperative you must add a check — e.g. call `yield()`, or test `isActive`, or call `ensureActive()` (those are covered as their own topic). The key mechanic here is simply: **no suspension or explicit check = cancellation is not observed.** ## CancellationException is special The exception thrown on cancellation is `kotlinx.coroutines.CancellationException`. The framework treats it as a **normal, expected** way for a coroutine to stop — it is **not** propagated to the parent as a failure and does **not** crash sibling coroutines or the parent scope. (Other exceptions do propagate as failures.) ## Propagation to children `cancel()` on a parent `Job` cancels all of its children too, recursively. Children's cancellation flows back so that `join()` on the parent completes only once everything has wound down. ## cancel() then join() Because `cancel()` returns immediately and the coroutine may still be finishing (running cleanup, suspending one last time), you often pair it with `job.join()` to wait until it is fully done, or use the combined `job.cancelAndJoin()`.

  • Why pair cancel() with join()?
    cancel() only requests cancellation and returns immediately; join() waits until the coroutine has actually finished (including any cleanup). cancelAndJoin() does both.
  • Does cancelling a parent cancel its children?
    Yes. Cancellation propagates down the job hierarchy, cancelling all children recursively.

Like asking a runner to stop: they don't freeze mid-stride, they stop at the next checkpoint. If there are no checkpoints, they finish the lap.

saying these in an interview costs you the question

  • Says cancel() immediately kills/interrupts the coroutine like Thread.stop()
  • Thinks a busy CPU loop will be stopped by cancel()
  • Believes CancellationException crashes the parent scope
  • Confuses cancel() (request) with the coroutine actually finishing

context