What is the cancellation hazard when using runCatching inside a coroutine, and how do you handle it correctly?
answer
- Cancellation = CancellationException thrown at suspension points
- runCatching catches Throwable → swallows cancellation
- Rethrow CancellationException, then build failure
- Use ensureActive() / runSuspendCatching helper
- CancellationException is an IllegalStateException on JVM
basics
~10 srunCatching catches every exception, including the special one that signals a coroutine was cancelled. Swallowing that breaks cancellation, so you must re-throw CancellationException instead of treating it like a normal failure.
solid answer
~40 sCoroutine cancellation works by throwing `CancellationException` at suspension points. `runCatching` catches `Throwable`, so it also captures `CancellationException` and turns it into a `Result.failure`. If you then ignore or recover from that failure, the coroutine keeps running after it was supposed to stop — cancellation is broken, and structured concurrency leaks. The fix is to re-throw cancellation: after `runCatching`, check `exceptionOrNull()` and rethrow if it is a `CancellationException` (use `ensureActive()` or `currentCoroutineContext().ensureActive()`), or wrap the suspending call so cancellation propagates. Many teams use a custom `runCatchingCancellable`/`runSuspendCatching` helper that rethrows `CancellationException` before building the failure. Note the JVM gotcha: catching it by a broad `catch (e: Exception)` also swallows it since `CancellationException` is an `IllegalStateException`.
code
kotlin · 13 linesimport kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ensureActive
import kotlin.coroutines.coroutineContext
suspend inline fun <R> runSuspendCatching(block: () -> R): Result<R> =
try {
Result.success(block())
} catch (e: CancellationException) {
throw e // propagate cancellation
} catch (e: Throwable) {
coroutineContext.ensureActive() // belt-and-suspenders
Result.failure(e)
}go deeper
Recognizes that cancellation is signalled by an exception and shouldn't be ignored.
Knows runCatching swallows CancellationException and that you must rethrow it.
Writes a runSuspendCatching/ensureActive pattern and explains cooperative cancellation and structured concurrency impact.
Mandates a project-wide cancellation-safe catch helper, audits catch-all sites, and reasons about leaked work across scopes.
## How cancellation works Kotlin coroutines implement *cooperative cancellation*: when a `Job` is cancelled, the next **suspension point** (a `suspend` call, or an explicit `ensureActive()`/`yield()`) throws a `CancellationException`. That exception unwinds the coroutine so it stops cleanly. This is the mechanism behind structured concurrency. ## The hazard `runCatching` is defined to catch **everything**: ```kotlin public inline fun <R> runCatching(block: () -> R): Result<R> = try { Result.success(block()) } catch (e: Throwable) { Result.failure(e) } // catches CancellationException too! ``` So if your block suspends and the coroutine is cancelled mid-flight, the `CancellationException` is captured into a `Result.failure`. If your code then does `getOrElse { fallback }`, logs it as an error, retries, or otherwise *recovers*, you have **swallowed cancellation**. The coroutine continues working when its scope wanted it dead — wasted work, leaked resources, and broken parent-child cancellation. ```kotlin suspend fun fetch(): Data = runCatching { api.get() } // suspends; may be cancelled .getOrElse { Data.EMPTY } // BUG: also 'recovers' from cancellation ``` ## The correct pattern Re-throw `CancellationException`; only treat the rest as real failures. ```kotlin suspend inline fun <R> runSuspendCatching(block: () -> R): Result<R> = try { Result.success(block()) } catch (e: CancellationException) { throw e // let cancellation propagate } catch (e: Throwable) { Result.failure(e) } ``` Or, after a plain `runCatching`, assert liveness: ```kotlin val r = runCatching { api.get() } coroutineContext.ensureActive() // rethrows if cancelled r.getOrElse { Data.EMPTY } ``` `ensureActive()` throws `CancellationException` if the current job is no longer active, restoring correct propagation. ## A second JVM trap `kotlinx.coroutines.CancellationException` is a `kotlin.coroutines.cancellation.CancellationException`, which on the JVM extends `IllegalStateException` (a `RuntimeException`/`Exception`). So a broad `catch (e: Exception)` swallows it too — the cancellation hazard is not unique to `runCatching`; it applies to any catch-all in suspending code. ## Takeaways - Never let cancellation be 'handled' as an ordinary error. - Prefer a `runSuspendCatching`-style helper inside coroutines. - If you must use plain `runCatching`, call `ensureActive()` / rethrow `CancellationException` afterward. - The same discipline applies to any `catch (Throwable/Exception)` around suspend code.
- Why can't you just catch Exception instead of Throwable to avoid the problem?CancellationException extends IllegalStateException (a RuntimeException/Exception), so catch (e: Exception) still swallows it. You must explicitly rethrow CancellationException.
- What does ensureActive() do and when do you call it?It throws CancellationException if the current Job is no longer active. Call it after a catch-all (or periodically in long CPU loops) to restore correct cancellation propagation.
CancellationException is a fire alarm; runCatching is a soundproof room that muffles every noise — including the alarm — so people keep working while the building should be evacuating.
saying these in an interview costs you the question
- Claiming runCatching is cancellation-safe out of the box in coroutines
- Catching Exception and assuming cancellation is excluded
- Logging CancellationException as an error and continuing
- Retrying a runCatching failure without checking for cancellation
- Not knowing cancellation is delivered via a thrown exception at suspension points