Blog Infos
Author
Published
Topics
, , , ,
Published

 

When using Kotlin coroutines for repeating tasks — like polling APIs, updating data periodically, or scheduled tasks — understanding coroutine cancellation clearly is critical. Poorly handled cancellation can result in subtle bugs, infinite loops, or zombie coroutines.

This article clearly explains coroutine cancellation, the common pitfalls, and the best practices to manage it correctly.

Coroutine Cancellation Explained

When a coroutine scope is cancelled, Kotlin immediately signals coroutines running within that scope by throwing a special exception: CancellationException.

Important facts about CancellationException:

  • Origin: Generated exclusively by coroutine machinery, not by typical application logic.
  • When it’s thrown: After a coroutine scope has been cancelled, calling any built-in suspending function (delaywithContextawait, etc.) from within a cancelled scope throws a CancellationException.
  • How it’s handled differently:
  • When a coroutine ends due to a CancellationException, it’s not treated as an error—it’s considered a normal coroutine termination.
  • Unlike regular exceptions, CancellationException does not propagate upward through the coroutine’s job hierarchy. It ends silently and cleanly without alerting parent coroutines or crashing your application.

Important note:
For CancellationException to terminate a coroutine properly, it must reach the top of the call stack uncaught. Repeatedly catching and ignoring these exceptions can create zombie coroutines—running endlessly, doing no useful work, potentially holding locks or resources.

Example: What NOT to Do (Common Mistake)

Here’s a problematic coroutine loop that unintentionally catches all exceptions, including cancellation exceptions:

launch {
    while (true) {
        try {
            doWork()
            delay(1000) // delay inside try-catch, can throw CancellationException
        } catch (e: Exception) { // catches every exception!
            logError(e)
        }
    }
}

Why is this problematic?

  • The loop (while(true)) has no built-in cancellation check (isActive).
  • The broad catch block treats cancellation as a normal error.
  • It may result in infinite loops or misleading error logs.
Recommended Pattern (Best Practice)

Here’s the recommended best practice:

launch {
    while (isActive) {
        // rethrows immediately if the coroutine scope is cancelled
        coroutineContext.ensureActive()

        try {
            doWork()
        } catch (e: Throwable) {
            handleError(e)   // now only real errors land here
        }

        delay(1000)         // suspension outside the try/catch
    }
}

Why this works well:

  • while (isActive): Clearly expresses the loop will terminate upon coroutine cancellation.
  • coroutineContext.ensureActive(): Explicitly checks if the coroutine is still active and clearly distinguishes real cancellations from other exceptions.
  • Suspension (delay) outside try-catch: Provides an additional safeguard to ensure cancellation exceptions propagate cleanly (see additional note below).
Rogue Cancellation Exceptions (Special Cases)

Sometimes, CancellationException can appear from within your own logic—not from coroutine machinery itself. There are two common sources:

Cancelling a Deferred (clearly explained):

Consider a suspend function running inside your coroutine:

suspend fun fetchData() {
    val deferred = async {
        getData()
    }

    deferred.cancel()    // explicitly cancelling the deferred
    deferred.await()     // throws CancellationException because deferred was cancelled
}

Detailed explanation:

  • Deferred is a coroutine job holding a result or exception.
  • Calling deferred.cancel() completes this deferred with a stored CancellationException.
  • When later calling await() on a cancelled deferred, it throws that stored CancellationException.
  • This does not indicate the entire coroutine scope is cancelled, but that this specific deferred task was explicitly cancelled.
Java’s built-in CancellationException (brief reminder):

When using Java concurrency APIs (CompletableFuture, etc.), calling future.cancel() then future.get() throws a Java-specific CancellationException. Kotlin uses the same class, causing potential confusion. The double-check pattern resolves this ambiguity.

Double-Checked Cancellation (Advanced Best Practice):

To clearly and robustly handle both genuine coroutine cancellations and rogue cancellation exceptions:

launch {
    coroutineContext.ensureActive() // immediately rethrows if coroutine scope is cancelled
    while (isActive) {
        try {
            doWork()
        } catch (e: Throwable) {
            handleError(e)                  // handles other exceptions explicitly
        }
        delay(1000)
    }
}
Logic clearly explained:
  • Real coroutine scope cancellation:
    ensureActive() throws immediately → coroutine terminates gracefully without handling it as an error.
  • Rogue or other exceptions:
    Scope is still active → ensureActive() does nothing → handled as normal exception.

How ensureActive() works internally:
This single line:

coroutineContext.ensureActive()

Is effectively the same as:

if (!isActive) {
    throw CancellationException()
}

It explicitly checks the coroutine’s activity state, throwing a CancellationException if cancelled.

Additional Notes:

1) Placement of suspension points (like delay) outside try-catch:

while (isActive) {
    coroutineContext.ensureActive() // recommended for clean handling
    try {
        doWork()
    } catch (e: Exception) { // handle general exceptions
        logError(e)
    }
    delay(1000) // placed outside ensures clean cancellation exception propagation
}

Placing suspension (delay) outside try-catch blocks provides an additional safety measure allowing cancellation exceptions to propagate cleanly. However, the explicit use of ensureActive() inside the catch is a stronger guarantee against accidentally swallowing cancellations.

2) Catching specific exceptions explicitly:
Instead of catching all exceptions (Exception or Throwable), prefer catching explicit exception types:

try {
    performTask()
} catch (e: IOException) {
    // handle IO exceptions explicitly
} catch (e: ParseException) {
    // handle parsing exceptions explicitly
}

Explicit catching prevents unintended swallowing of CancellationException and maintains clear error handling logic.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

,

Mistakes you make using Kotlin Coroutines

Kotlin Coroutines offer a powerful, yet deceptively simple solution for managing asynchronous tasks. However, their ease of use can sometimes lead us into unforeseen pitfalls.
Watch Video

Mistakes you make using Kotlin Coroutines

Marcin Moskala
Developer during the day, author at night, trainer
Kt. Academy

Mistakes you make using Kotlin Coroutines

Marcin Moskala
Developer during the ...
Kt. Academy

Mistakes you make using Kotlin Coroutines

Marcin Moskala
Developer during the day, ...
Kt. Academy

Jobs

Summary of Clear Best Practices:
  • Use while (isActive)
    Clearly expresses your intent to stop execution when the coroutine is cancelled.
  • Apply double-checked cancellation using coroutineContext.ensureActive()
    This ensures genuine coroutine cancellations are rethrown immediately, while rogue CancellationExceptions or real errors can still be handled appropriately.
  • Catch only specific exceptions explicitly
    Avoid using broad catch (e: Exception) unless absolutely necessary. This prevents unintentionally swallowing CancellationException and ensures proper cancellation behavior.
Final Thoughts

By clearly understanding coroutine cancellations and distinguishing them from regular exceptions, you ensure your coroutine-based repeating tasks terminate gracefully, avoid zombie states, and produce accurate, helpful error logging.

Happy coding!

This article was previously published on proandroiddev.com.

Menu