Cancellation in Kotlin Coroutines - Internal working

Hello Folks,
Today, we’re diving into a crucial topic in coroutines: coroutine cancellation. This is vital because, in Android, every coroutine is tied to a view or lifecycle, and when that view is destroyed, the coroutine should also end. Similarly, other coroutines need to wrap up when the app closes.
This cancellation behavior is incredibly important, as many other libraries require complex mechanisms to manage it. However, in Kotlin Coroutines, it’s both straightforward and safe, making it easier to ensure proper coroutine cleanup.
Let’s explore this key property and see how coroutines handle cancellation so efficiently.
This mechanism is also used on the backend, especially when we deal with long connections, like WebSockets or long polling. What is more, cancellation often works in situations we do not even realize, to free resources and to make our application more efficient.
Cancellation is crucial, and many classes and libraries use suspending functions mainly to support it. There’s a valid reason for this focus: a robust cancellation mechanism is invaluable. Simply killing a thread is a poor solution, as it doesn’t allow for proper cleanup of resources or closing of connections. Likewise, forcing developers to manually check some status repeatedly isn’t ideal. The need for an effective cancellation method has been longstanding, but Kotlin Coroutines provide a solution that’s surprisingly straightforward, user-friendly, and safe.
ViewModelScope Cancellation:
When you use viewModelScope
or lifecycleScope
, cancellation becomes straightforward—you don't need to manually manage it. When the ViewModel is destroyed, viewModelScope
is automatically cancelled, along with all the coroutines it contains.
How does it work internally? Let’s dive into some code to understand the mechanics:




Explanation:
- The
viewModelScope
operates with a structure that involves setting a tag to the ViewModel. This is done viasetTagIfAbsent
, which takes two parameters: a key and aCloseableCoroutineScope
. TheCloseableCoroutineScope
implements aCancelable
interface, providing a way to cancel coroutines. - Inside
setTagIfAbsent
, there's a HashMap calledmBagOfTags
. This map stores various tags and corresponding scopes. When a ViewModel is about to be cleared, theonClear
method is called. - During
onClear
, it invokescloseWithRuntimeException
, which checks if the scope is an instance ofCloseable
. If so, it closes (or cancels) that scope, effectively cancelling all coroutines running within it. This behavior ensures that when the ViewModel is destroyed, all associated coroutines are automatically cancelled.
That’s the internal mechanism of viewModelScope
, providing a way to clean up and cancel coroutines when the ViewModel is no longer in use. This automation means you don't have to worry about manually handling coroutine cancellation—it happens as part of the lifecycle.
What if we create a CoroutineScope manually that isn’t bound to viewModelScope
or lifecycleScope
, where cancellation is automated?
In that case, you can cancel the job manually by calling job.cancel()
. Let's see an example of how to do that.
suspend fun main(): Unit = coroutineScope {
val job = launch {
repeat(1_000) { i ->
delay(200)
println("Printing $i")
}
}
delay(1100)
job.cancel()
job.join()
println("Cancelled successfully")
}
// (0.2 sec)
// Printing 0
// (0.2 sec)
// Printing 1
// (0.2 sec)
// Printing 2
// (0.2 sec)
// Printing 3
// (0.2 sec)
// Printing 4
// (0.1 sec)
// Cancelled successfully
Explanation:
- When you cancel a coroutine, its state changes to “cancelling.”
- In the code above, you can see that after 200 ms, it prints 5 times, indicating about 1 second of execution. But then, after an additional 100 ms, the coroutine gets cancelled, and it stops with “Cancelled successfully.”
- When cancellation occurs, the coroutine’s state transitions to “cancelling,” and it throws a
CancellationException
, signaling that the coroutine has been interrupted and is in the process of stopping. job.join()
: This suspends the current coroutine until the specifiedjob
completes. This means it waits until the other coroutine has fully stopped and all its resources are released.
Let’s look at the order and how this cancellation will work:
- All the children of this job are also canceled.
- The job cannot be used as a parent for any new coroutines.
- At the first suspension point, a
CancellationException
is thrown. If this coroutine is currently suspended, it will be resumed immediately withCancellationException
.CancellationException
is ignored by the coroutine builder, so it is not necessary to catch it, but it is used to complete this coroutine body as soon as possible. - Once the coroutine body is completed, and all its children are completed too, it changes its state to “Cancelled”.
You can catch a CancellationException
with a try-catch block. But why bother catching it? After all, it doesn't really harm anything, right?
Well, sometimes you need to clean up after a coroutine is cancelled—like closing resources or rolling back changes. In those cases, catching the exception is your way of handling the cleanup process. It's all about keeping things tidy when the coroutine gets the axe. 🔥🧹
Let’s check out the code:
suspend fun main(): Unit = coroutineScope {
val job = launch {
try {
repeat(1_000) { i ->
delay(200)
println("Printing $i")
}
} catch (e: CancellationException) {
println("Cancelled with $e")
} finally {
println("Finally")
}
}
delay(700)
job.cancel()
job.join()
println("Cancelled successfully")
delay(1000)
}
// (0.2 sec)
// Printing 0
// (0.2 sec)
// Printing 1
// (0.2 sec)
// Printing 2
// (0.1 sec)
// Cancelled with JobCancellationException...
// Finally
// Cancelled successfully
Explanation:
The code above is pretty straightforward, so let’s keep going.
What if, after cancelling a job, you want to make sure it’s really cancelled? No worries — Kotlin Coroutines has got you covered with these three states to check on:
- job.isActive
- job.isCompleted
- job.isCancelled

Job Creation States:
- Jobs are usually created in the active state, meaning they start immediately.
- Some coroutine builders have an optional
start
parameter. If set to [CoroutineStart.LAZY], the coroutine is created in the new state and can be activated by invokingstart
orjoin
.
Active State:
- A job is considered active while the coroutine is executing until it completes, or if it fails or is canceled.
- Canceling and Cancelled States:
- If an active job fails with an exception, it transitions to the canceling state.
- A job can be manually canceled at any time with the
cancel
function, which also transitions it to the cancelling state. - The job becomes canceled when it finishes executing its work and all its children complete.
Completing and Completed States:
- When an active coroutine’s body completes or
CompletableJob.complete
is called, the job transitions to the completing state. - The job remains in the completing state while it waits for all its children to complete.
- Once all children are completed, the job transitions to the completed state.
- Note that for external observers, a job in the completing state still appears active, but internally, it’s waiting for its children to finish.
When you cancel a coroutine scope, it’s a done deal — you can’t use it to launch new coroutines. A cancelled scope can’t be used as a parent for anything new. If you want to keep going, you have to create a new scope. A scope with a cancelled job is like an expired ticket: it won’t get you anywhere.
Let’s take a look at some code to see this in action:
suspend fun main() {
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
repeat(1_000) { i ->
delay(200)
println("Printing $i")
}
}
delay(1100)
scope.cancel()
scope.launch {
println("New Scope")
}
println("Cancelled successfully")
delay(1000)
}
Output:-
Printing 0
Printing 1
Printing 2
Printing 3
Printing 4
Cancelled successfully
Explanation:
- As you can see on the above code output, we canceled the scope and launched it again but didn’t execute.
Cancellation in a coroutine scope internally:
- To understand why this happens, let’s dig into the internal codebase.
- Here’s where we’ll see what’s going on under the hood:

- Let’s break down how this mechanism impacts the cancellation of child coroutines:
The Role of a Job:
- A
Job
represents a unit of work in a coroutine and acts as a parent for all child coroutines within the same scope. It tracks their state (active, cancelled, or completed). If theCoroutineContext
doesn't contain aJob
, a default one is created to ensure centralized control over coroutine management.
Cancellation Behavior:
- When you create a
CoroutineScope
with a context that includes aJob
, any child coroutine launched within this scope becomes part of thatJob
. This allows cancellation to cascade, meaning cancelling the parentJob
also cancels all child coroutines. - By calling
CoroutineScope.cancel()
on a scope with aJob
, you trigger the cancellation process for all child coroutines. This initiates an orderly shutdown, causing coroutines to complete their work or exit at the next suspension point (likedelay
).
Application in Your Code:
- In your code, you created a
CoroutineScope
and launched multiple coroutines within it. When you calledscope.cancel()
, it triggered the cancellation process for the parentJob
and all its child coroutines. The orderly cancellation process helps ensure that all operations within the scope are stopped consistently and resources are released appropriately.w
As you guys already know that cancellation happens only at suspension point. but what if our thread is blocked, there is an I/O operation is happening inside coroutine which is large process. What will happen at the time when cancel the job? We can simulate the process via using Thread.sleep, but never try this situation in your live project.
Let’s look at the scenario:-
suspend fun main(): Unit = coroutineScope {
val job = Job()
launch(job) {
repeat(1_000) { i ->
Thread.sleep(200) // We might have some
// complex operations or reading files here
println("Printing $i")
}
}
delay(1000)
job.cancelAndJoin()
println("Cancelled successfully")
delay(1000)
}
// Printing 0
// Printing 1
// Printing 2
// ... (up to 1000)
Explanation:
- The example below presents a situation in which a coroutine cannot be cancelled because there is no suspension point inside it (we use
Thread.sleep
instead ofdelay
). The execution needs over 3 minutes, even though it should be cancelled after 1 second. - You guys can decompile the code and understand also.
Solution:
- To overcome from this problem we can use yield().
- Now someone will ask what the hack is yield(). But wait, before we dive deeper into this let’s look at the code.
suspend fun main(): Unit = coroutineScope {
val job = Job()
launch(job) {
repeat(1_000) { i ->
Thread.sleep(200) // We might have some
yield()
// complex operations or reading files here
println("Printing $i")
}
}
delay(1000)
job.cancelAndJoin()
println("Cancelled successfully")
delay(1000)
}
Output:-
Printing 0
Printing 1
Printing 2
Printing 3
Printing 4
Cancelled successfully
- It is a good practice to use
yield
in suspend functions, between blocks of non-suspended CPU-intensive or time-intensive operations. - Let’s look at what the hack is yield().

In normal word yield is mean to give way to someone or something that one can no longer resist.
In our terms:
- Thread Yielding: By calling
yield()
, the current coroutine gives control back to the dispatcher, allowing other coroutines to run. This is particularly useful for creating fairness among coroutines sharing the same dispatcher. - Cancellable: If the
Job
associated with the coroutine is cancelled whileyield()
is called or while waiting for dispatch, the coroutine immediately resumes with aCancellationException
. This provides a prompt cancellation guarantee, ensuring cancellation is respected even ifyield()
was ready to return. - Cancellation Check: Even if
yield()
doesn't suspend, it still checks if the coroutine has been cancelled throughensureActive()
. This adds an additional level of safety to prevent unwanted coroutine continuation after cancellation.
ensureActive():

It will check that job is still active or not using isActive which we already so earlier.
Try it out, and put a question in the comments if you guys have any. I’ll see you guys soon with some new amazing topics.