© Interview RuntimeThis content is copyright. Copying is disabled; you may quote a short excerpt with a link to the page.

$59 until 31 October, then $99

runCatching swallows coroutine cancellation in Kotlin

A reproducible write-up of the runCatching CancellationException bug: the test that proves it, the shapes it hides in, and what each fix costs you.

A gold cancellation signal travels down a coroutine, is caught by a box labelled runCatching, and the path beyond it keeps going in gold where it should have stopped.

In short

  • runCatching catches Throwable, and CancellationException is a Throwable, so a cancelled coroutine gets a Result.failure and keeps executing until its next cancellable suspension point.
  • The Job stays cancelled after the swallow, but every non-suspending line after it still runs: error states, logs, analytics and retries.
  • A try/catch (e: Exception) around a loop body can turn cancellation into a loop that never suspends again and spins on its thread.
  • Rethrowing CancellationException is the safe default. A CancellationException can also come from another coroutine, for example from Deferred.await, so when you must catch everything, the kotlinx.coroutines maintainers suggest calling ensureActive() first: it throws only if your own coroutine was cancelled, and anything else is handled as a normal failure.
  • A Result-returning API should never contain a CancellationException, and a repository test should include a cancellation case to prove it.

runCatching { api.profile() } looks like careful code. It reads as "this call can fail, and I handle it". In a coroutine it also means "if this call is cancelled, I handle that as an error and carry on". This article is a reproducible write-up of the runCatching CancellationException problem: a test that passes and proves the bug, the reason Kotlin coroutine cancellation breaks, the other shapes the same bug takes, and the fixes with their costs.

In coroutine-heavy Android codebases it rarely looks wrong in a diff. The coroutines interview questions article gave it one paragraph and a helper. This is the long version, including the case where the simple helper is wrong.

A test that passes and proves the bug

Start with a loader that does what many real ones do: call a suspending API, map success to content, map failure to an error, then finish. The events list stands in for UI state writes, logs or analytics.

import kotlinx.coroutines.CancellationException

class ProfileLoader(private val api: suspend () -> String) {
    val events = mutableListOf<String>()

    suspend fun load() {
        runCatching { api() }
            .onSuccess { events += "content" }
            .onFailure { e ->
                events += "error:" + (e is CancellationException)
            }
        events += "end"
    }
}

The test uses only stable APIs from kotlinx-coroutines-test. The fake API signals that it started, then suspends forever with awaitCancellation(), which is how a slow network call looks from the coroutine's side. The test cancels the job while the call is in flight.

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest

class ProfileLoaderTest {
    @Test
    fun cancelledLoadIsReportedAsAnError() = runTest {
        val started = CompletableDeferred<Unit>()
        val loader = ProfileLoader(api = {
            started.complete(Unit)
            awaitCancellation()
        })

        val job = launch { loader.load() }
        started.await()
        job.cancel()
        job.join()

        assertTrue(job.isCancelled)
        assertEquals(listOf("error:true", "end"), loader.events)
    }
}

Both assertions hold. The job is cancelled, and the loader still recorded an error and ran to the end. In an app, that error is an error state written for a screen the user already left, a crash reporter entry for a request nobody wanted, or a retry that starts on the way out. Keep this test. It becomes the regression test for every fix below.

Why runCatching and CancellationException do not mix

Kotlin coroutine cancellation is cooperative, and the cooperation is an exception. When a job is cancelled, the suspended continuation is resumed with a CancellationException, and that exception is supposed to unwind the stack to the coroutine builder. The builder sees it, treats it as normal cancellation rather than failure, and does not report it to the parent or to a CoroutineExceptionHandler.

runCatching is try { Result.success(block()) } catch (e: Throwable) { Result.failure(e) }. On the JVM, kotlinx.coroutines.CancellationException is a typealias for java.util.concurrent.CancellationException, which extends IllegalStateException. So catch (e: Throwable), catch (e: Exception), catch (e: RuntimeException) and catch (e: IllegalStateException) all stop the unwind.

Catching it does not un-cancel anything. The Job stays in its cancelled state, isActive is false, and the next cancellable suspension point throws again: delay, withContext, await, a Retrofit suspend call, anything built on suspendCancellableCoroutine. What you lose is everything between the catch and that next check. Non-suspending code runs in full. If there is no further suspension point, the body completes normally and the job still ends as cancelled, which is exactly what the test shows.

This is also why the bug hides so well. If the loader is called inside withContext(Dispatchers.IO), the caller never sees the swallowed result: withContext has a prompt cancellation guarantee and throws CancellationException on the way out if the caller was cancelled. The error state often gets thrown away one frame up, so it only shows in the side effects that already happened.

When the swallow never ends

A single swallowed exception is a stray side effect. A loop makes it worse. This polling pattern appears in a lot of apps, usually with a comment saying the loop must survive network errors.

fun startPolling() {
    viewModelScope.launch {
        while (true) {
            try {
                _status.value = repo.fetchStatus()
                delay(30_000)
            } catch (e: Exception) {
                logger.warn("poll failed", e)
            }
        }
    }
}

Cancel the scope while it is waiting in delay. The CancellationException is caught and logged. The loop goes round. If fetchStatus() is a cancellable suspend call, it sees a cancelled job and throws at once, without suspending. If it does not suspend at all, for example because it returns a cached value, it returns normally and delay throws at once instead. Either way the exception is caught and logged, and the loop goes round again. Nothing in the loop ever suspends again, so it spins on its thread and floods the log. With viewModelScope, that thread is the main thread. Changing the condition to while (isActive) stops the spin, but the loop still logs the cancellation once as a failure, so it treats the symptom.

The same bug in other shapes

Searching for runCatching finds only one form. These are the others I look for in review.

  • `catch (e: Exception)` and `catch (e: Throwable)` around any suspend call. Same mechanism, no Result in sight.
  • Result-returning repository APIs. suspend fun profile(): Result<Profile> = runCatching { api.profile() } moves the bug behind an interface. The caller maps every failure to an error state, and the cancellation arrives as one of those failures.
  • Retry helpers. A catch (e: Exception) with a counter retries on cancellation. If the backoff uses delay, the loop stops by accident at the delay. If there is no delay, or the first catch reports the attempt before the delay, cancellation burns through the retries and reports each one.
  • `try` around `collect`. The Flow.catch operator is designed for this problem: it handles exceptions from upstream, and it rethrows exceptions from downstream and the cancellation of the collecting coroutine. A hand-written try/catch (e: Exception) around collect has none of that and catches both.
  • `supervisorScope` with `runCatching { deferred.await() }`. A supervisor turns a child's failure into data you can read from await(), which makes this pattern look correct. It does nothing for the parent's cancellation. The awaits throw, the catch turns them into nulls, and the code builds a half-empty result anyway.
suspend fun <T> retry(
    times: Int,
    onAttemptFailed: (Exception) -> Unit,
    block: suspend () -> T,
): T {
    var last: Exception? = null
    repeat(times) {
        try {
            return block()
        } catch (e: Exception) {
            // Runs for cancellation too, once per attempt
            onAttemptFailed(e)
            last = e
        }
    }
    throw last ?: IllegalArgumentException("times must be > 0")
}

Fixes, and what each one costs

Rethrow CancellationException explicitly

The smallest fix at a single call site is one line at the top of the catch block. If you prefer separate clauses, order matters: clauses are tried top to bottom, so the CancellationException clause must come before the broader one.

try {
    _status.value = repo.fetchStatus()
} catch (e: Exception) {
    if (e is CancellationException) throw e
    logger.warn("poll failed", e)
}

It costs nothing at runtime and it is obvious in review. The cost is repetition: every catch site has to remember it, and the ones that forget look identical to the ones that do not.

A helper that knows about coroutines

A shared helper removes the repetition. The first version rethrows every CancellationException. The second rethrows only if the current coroutine is the one being cancelled, and turns any other CancellationException into a failure.

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive

// Blanket rethrow: the safe default
inline fun <T> runSuspendCatching(block: () -> T): Result<T> =
    try {
        Result.success(block())
    } catch (e: CancellationException) {
        throw e
    } catch (e: Exception) {
        Result.failure(e)
    }

// Rethrows only our own cancellation
suspend inline fun <T> suspendRunCatching(
    block: () -> T,
): Result<T> =
    try {
        Result.success(block())
    } catch (e: CancellationException) {
        currentCoroutineContext().ensureActive()
        Result.failure(e)
    } catch (e: Exception) {
        Result.failure(e)
    }

Both are inline, so the lambda can call suspending functions when the helper is used inside a coroutine. Both catch Exception, not Throwable, so an OutOfMemoryError or a StackOverflowError still crashes instead of becoming a Result. That is a deliberate choice, and it is the second difference from the standard runCatching.

In the second helper, ensureActive() throws the job's own CancellationException if the current coroutine is cancelled. If the coroutine is still active, the exception came from somewhere else, and it becomes a failure. That sounds strictly better. The next section explains why I still default to the first one.

Catch the exceptions you can handle

suspend fun profile(): Result<Profile> =
    try {
        Result.success(api.profile())
    } catch (e: IOException) {
        Result.failure(e)
    } catch (e: HttpException) {
        Result.failure(e)
    }

Here IOException is java.io.IOException and HttpException is Retrofit's. Neither is a supertype of CancellationException, so cancellation passes through without any special handling. This is the fix I prefer at repository boundaries because it also documents which failures the API produces. The cost is that an unexpected exception crashes instead of showing an error state, which is the behavior you want in debug and a judgment call in release.

When the CancellationException is not yours

Not every CancellationException means the current coroutine was cancelled. Two common sources reach a coroutine that is still active.

A kotlinx.coroutines maintainer spells this out in issue 1814: an operation such as Deferred.await can throw a CancellationException it stored from another coroutine. If you must catch every exception, and you usually do not need to, the pattern they suggest is to call ensureActive() when the exception is a CancellationException, which throws if your own coroutine was cancelled, and only then handle the exception. The same comment warns that a CancellationException is not always safe to ignore even when your coroutine is active.

  • `withTimeout`. It throws TimeoutCancellationException, a subclass of CancellationException, out to a caller that is still running.
  • Awaiting a `Deferred` someone else cancelled. If a cache or a refresh cancels a shared async, every caller of await() gets a CancellationException while its own job is active.

With the blanket rethrow, that exception keeps unwinding. When it reaches a launch, the builder treats it as cancellation, not failure: the coroutine ends without a trace, nothing is reported to the parent, and no handler runs. The user sees a spinner that never resolves. The ensureActive() helper returns a failure instead, which is the behavior you wanted in both cases.

So why keep the blanket rethrow as the default? Because the two helpers fail in different directions. The blanket version fails quiet: in a rare case, work stops without an error. Swallowing fails loud and wide: cancelled work keeps writing state, retrying and looping on every cancellation, which is the common case. The ensureActive() version sits between them, and it has its own trap. It returns a Result holding a CancellationException from a coroutine that is still active. Pass that exception to any other code that rethrows cancellation, such as getOrThrow() inside a function using the first helper, and the quiet stop comes back one layer up.

The better fix for the foreign cases is at the call site that creates them. Use withTimeoutOrNull when a timeout is an expected outcome. When you await a Deferred you do not own, catch the CancellationException right there, check ensureActive(), and translate it into a domain error that is not a CancellationException. Then the general-purpose helper can stay simple and rethrow everything.

class StaleLoadException(cause: Throwable) :
    Exception("shared load was cancelled", cause)

suspend fun awaitShared(load: Deferred<Profile>): Profile =
    try {
        load.await()
    } catch (e: CancellationException) {
        currentCoroutineContext().ensureActive()
        throw StaleLoadException(e)
    }

Making code review catch it

People miss this in review because the wrong code and the right code look the same. A static check does better than a checklist item. The rule I would write, as a custom detekt rule or an Android Lint check, has two parts.

  1. Flag a runCatching call whose lambda contains a call to a suspending function.
  2. Flag a catch clause typed Throwable, Exception, RuntimeException or IllegalStateException whose try block contains a suspend call, unless an earlier clause catches CancellationException or the catch body rethrows it.

Both parts need type resolution to know which calls suspend, so in detekt the rule only works when run with type resolution enabled. Pair it with a test convention: every repository or use case that returns Result gets a cancellation test shaped like the one at the top of this article. The assertion flips from listOf("error:true", "end") to an empty list once the fix is in.

I wrote a book on Android interviews built around follow-ups like this: https://interviewruntime.com/android

Questions readers ask

© 2026 Mike Salari, Interview Runtime. All rights reserved. Quoting a short excerpt with a link to this page is welcome; republishing the article, in full or in part, is not.

Practice the follow-up, not the first answer.

Interview Runtime is the interview book plus 90 days of Guided Practice: you answer first, see the one gap that would cost the point, then answer again when one fact changes.