
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 (
delay
,withContext
,await
, etc.) from within a cancelled scope throws aCancellationException
. - 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:
- A
Deferred
is a coroutine job holding a result or exception. - Calling
deferred.cancel()
completes this deferred with a storedCancellationException
. - When later calling
await()
on a cancelled deferred, it throws that storedCancellationException
. - 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
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 rogueCancellationException
s or real errors can still be handled appropriately. - Catch only specific exceptions explicitly
Avoid using broadcatch (e: Exception)
unless absolutely necessary. This prevents unintentionally swallowingCancellationException
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.