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

runCatching swallows cancellation, and code review rarely catches it

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; checking ensureActive() after catching tells your own cancellation apart from a foreign one, at the cost of a more subtle contract.
  • 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.

I see this pattern in most code reviews I do for coroutine-heavy Android codebases, and 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. fetchStatus() hits a cancellable suspension point, sees a cancelled job, and throws immediately without suspending. It is caught and logged 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 actually 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.

  • `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 quietly, 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.

How this shows up as an interview follow-up

In a senior Android loop I rarely ask about this directly. It comes up as a follow-up when a candidate's design wraps every repository call in runCatching and returns Result, which is a reasonable design until cancellation enters the conversation.

Asked in the loopYour repository wraps each call in runCatching and returns Result. The user leaves the screen mid-request. What happens?

The answer that loses the point

viewModelScope is cancelled in onCleared(), so the request is cancelled and nothing else runs.

The answer that keeps it

The request is cancelled, but the CancellationException is caught by runCatching and returned as Result.failure. The coroutine keeps running until its next cancellable suspension point, so the onFailure branch writes an error state and anything non-suspending after it runs too. I would catch the specific IO and HTTP exceptions at that boundary, or use a helper that rethrows CancellationException, and add a runTest case that cancels mid-call and asserts no error was recorded.

Follow-up: "Now there is a withTimeout inside that block. What does your helper do with it?"

The follow-up is where the score is decided. The strong answer names TimeoutCancellationException, says the blanket rethrow lets it end a launch silently, and proposes withTimeoutOrNull or a translated domain error. The weak answer says the helper handles it, because it catches everything. That is the same bug in a new place.

Practicing the follow-up

This kind of question rewards having run the test, not having read about it. Android Interview Training is built around the same structure: the question, the answer I would score, the follow-up I would ask next, and the trap behind it. The free sample shows the format before you decide anything.

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.