skip to content

Why can a suspending cleanup call inside a finally block fail to run after a coroutine is cancelled, and how does NonCancellable fix it?

level: juniorimportance: must knowfreq 55%

answer

  1. Cancel = throw at next suspend point
  2. finally suspend call -> CancellationException, skipped
  3. Non-suspending finally still runs
  4. withContext(NonCancellable) lets cleanup suspend
  5. Keep it short, cleanup only

basics

~10 s

When a coroutine is cancelled, trying to suspend again (like awaiting a call) in finally immediately throws cancellation, so cleanup is skipped. Wrapping that cleanup in withContext(NonCancellable) lets it suspend and complete.

solid answer

~30 s

Cancellation in Kotlin coroutines works by making suspension points throw CancellationException once the Job is cancelled. A cancelled coroutine is in the Cancelling state, so any suspend call inside a finally block (e.g. delay, an HTTP call, closing a resource that suspends) throws CancellationException immediately and the cleanup never finishes. Non-suspending cleanup in finally still runs. To run suspending cleanup, wrap it in withContext(NonCancellable) { ... }: NonCancellable is a special Job that is always active, so suspension points inside it do not observe the cancellation and the block completes normally before the exception propagates. Keep these blocks short and cleanup-only.

code

kotlin · 14 lines
kotlin
suspend fun example() = coroutineScope {
    val job = launch {
        try {
            repeat(1000) { delay(50) }
        } finally {
            withContext(NonCancellable) {
                delay(100)        // would throw without NonCancellable
                println("cleanup done")
            }
        }
    }
    delay(120)
    job.cancelAndJoin()           // prints "cleanup done"
}

go deeper

for a junior

Knows cancellation throws at suspend points and that withContext(NonCancellable) is the tool for suspend cleanup.

for a middle

Explains the Cancelling state, distinguishes suspend vs non-suspend cleanup, and uses NonCancellable narrowly.

for a senior

Articulates that NonCancellable is an always-active Job and warns against doing real work inside it.

for a principal

Frames it as a structured-concurrency invariant and reasons about timeouts/leaks if cleanup is allowed to be uncancellable indefinitely.

## The problem Kotlin coroutine cancellation is **cooperative** and is delivered at **suspension points**. When you call `job.cancel()`, the coroutine's `Job` moves to the *Cancelling* state. From that moment, every `suspend` call (every point where the coroutine could suspend) checks the job and throws `CancellationException`. A `try/finally` is the normal way to release resources: ```kotlin val job = launch { try { work() } finally { // runs on cancellation too delay(100) // SUSPEND POINT -> throws CancellationException closeAsync() // never reached } } ``` The `finally` block *starts* running on cancellation (good), but the moment it hits a **suspending** call like `delay`, that call sees the job is already cancelled and throws `CancellationException` right away. So suspending cleanup is skipped. Note: **non-suspending** code in `finally` (plain function calls, `close()` on a regular stream) runs fine. Only *suspend* cleanup is affected. ## The fix: withContext(NonCancellable) `NonCancellable` is a singleton `Job` (it implements `Job`) that reports itself as **always active** and ignores cancellation. By switching the coroutine context to it with `withContext`, suspension points inside no longer see the cancellation: ```kotlin val job = launch { try { work() } finally { withContext(NonCancellable) { delay(100) // allowed to suspend closeAsync() // completes } } } ``` Inside the block the coroutine is uncancellable, so the cleanup finishes; afterwards the original `CancellationException` continues to propagate and the coroutine ends as cancelled. ## Key terms - **suspend / suspension point**: a place a coroutine can pause; this is where cancellation is checked and thrown. - **CancellationException**: the signal thrown to unwind a cancelled coroutine; normally swallowed by the machinery. - **Job state Cancelling**: cancellation requested, cleanup running, not yet finished. - **withContext(ctx)**: runs a block in a modified context and returns its result. ## Discipline Use `NonCancellable` **only** for short cleanup/finalization. Doing real work in it defeats cancellation and can hang the system.

  • Does ordinary, non-suspending cleanup in finally need NonCancellable?
    No. Only suspending calls throw on cancellation. Plain code (e.g. a synchronous close()) in finally runs normally.
  • What is NonCancellable, technically?
    A singleton object implementing Job that is permanently active and ignores cancellation; you install it via withContext(NonCancellable).

It's like a fire alarm that cuts the power: you can still walk out (plain code), but you can't take the elevator (suspend) — NonCancellable is the one emergency elevator that still works.

saying these in an interview costs you the question

  • Claims cancellation stops the coroutine instantly without running finally
  • Says you should wrap all of the coroutine body in NonCancellable
  • Thinks NonCancellable starts a new coroutine or thread
  • Believes plain (non-suspend) finally code is also skipped on cancellation

context