Kotlin coroutine lifecycle: the mental model
A Kotlin coroutine is a lightweight concurrent task that runs inside a CoroutineScope and is controlled by a Job.
The lifecycle is mostly about the state of that Job.
At a high level:
New → Active → Completing → Completed
↘ Cancelling → Cancelled
Most of the time, you mainly think about:
Active → Completed
Active → Cancelled
1. Coroutine starts in a scope
You usually launch a coroutine with launch or async:
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
println("Coroutine started")
delay(1000)
println("Coroutine finished")
}
println("Job is active: ${job.isActive}")
job.join()
println("Job is completed: ${job.isCompleted}")
}
Here:
runBlockingcreates a coroutine scope.launchstarts a child coroutine.jobrepresents that coroutine’s lifecycle.join()waits until the coroutine finishes.
2. The main lifecycle states
New
A coroutine can be created but not started yet if you use CoroutineStart.LAZY.
val job = launch(start = CoroutineStart.LAZY) {
println("Started later")
}
At this point, the coroutine exists but has not begun running.
You start it with:
job.start()
or:
job.join()
Active
Once started, the coroutine becomes active.
val job = launch {
delay(1000)
}
While active, it can:
- run code
- suspend
- resume later
- launch child coroutines
- be cancelled
Suspension does not mean the coroutine is inactive. A coroutine waiting in delay, for example, is still active.
Completing
When the coroutine body finishes, the coroutine enters a completing phase.
This matters especially if it has child coroutines:
val job = launch {
launch {
delay(1000)
println("Child done")
}
println("Parent body done")
}
The parent coroutine’s body may finish quickly, but the parent Job is not fully completed until its children complete.
This is part of Kotlin’s structured concurrency model.
Completed
A coroutine is completed when:
- its body finishes normally
- all of its child coroutines are completed
Example:
val job = launch {
delay(500)
println("Done")
}
job.join()
println(job.isCompleted)
Cancelling
When cancellation is requested, the coroutine enters a cancelling state.
val job = launch {
repeat(10) {
delay(500)
println("Working $it")
}
}
delay(1200)
job.cancel()
Cancellation is cooperative. The coroutine needs to reach a cancellable suspension point or check cancellation manually.
Common cancellable suspension points include:
delaywithContextyield- many
kotlinx.coroutinessuspending functions
Cancelled
Once cancellation cleanup finishes, the coroutine becomes cancelled.
val job = launch {
try {
delay(5000)
} finally {
println("Cleanup")
}
}
delay(100)
job.cancelAndJoin()
println(job.isCancelled)
cancelAndJoin() cancels the coroutine and waits for it to fully finish.
3. Cancellation is cooperative
This coroutine cancels easily:
val job = launch {
while (isActive) {
println("Working")
delay(100)
}
}
delay(500)
job.cancelAndJoin()
This one may not cancel quickly:
val job = launch {
while (true) {
// CPU-heavy work, no suspension, no cancellation check
}
}
For CPU-bound work, check cancellation manually:
val job = launch {
while (isActive) {
// Do a chunk of work
}
}
Or call:
ensureActive()
Example:
val job = launch {
repeat(1_000_000) {
ensureActive()
// Do work
}
}
4. Parent-child relationship
Coroutines launched inside another coroutine become its children:
fun main() = runBlocking {
val parent = launch {
launch {
delay(1000)
println("Child finished")
}
println("Parent body finished")
}
parent.join()
println("Parent fully completed")
}
Output is conceptually:
Parent body finished
Child finished
Parent fully completed
The parent waits for the child before becoming completed.
5. Cancelling a parent cancels its children
val parent = launch {
launch {
repeat(10) {
delay(300)
println("Child working")
}
}
}
delay(700)
parent.cancelAndJoin()
When the parent is cancelled, its child coroutine is cancelled too.
This is one of the most important lifecycle rules.
6. Child failure usually cancels the parent
By default, if a child coroutine fails with an exception, the parent is cancelled:
val parent = launch {
launch {
throw RuntimeException("Child failed")
}
launch {
delay(1000)
println("This may be cancelled")
}
}
This behavior keeps concurrent work grouped together: if one part fails, the whole operation usually fails.
If you want children to fail independently, use supervisorScope or SupervisorJob.
supervisorScope {
launch {
throw RuntimeException("Child failed")
}
launch {
delay(1000)
println("Still runs")
}
}
7. launch vs async
Both create coroutines, but they expose results differently.
launch
Use launch for fire-and-forget work that does not return a value.
val job: Job = launch {
println("Doing work")
}
It returns a Job.
async
Use async for work that returns a value.
val deferred: Deferred<Int> = async {
delay(500)
42
}
val result = deferred.await()
println(result)
It returns a Deferred<T>, which is also a Job.
await() waits for completion and returns the result, or throws the coroutine’s exception.
8. Cleanup with finally
A coroutine can clean up when it completes or is cancelled:
val job = launch {
try {
repeat(10) {
delay(300)
println("Working $it")
}
} finally {
println("Cleaning up")
}
}
delay(800)
job.cancelAndJoin()
If you need to call a suspending function during cleanup, use NonCancellable:
val job = launch {
try {
delay(5000)
} finally {
withContext(NonCancellable) {
delay(300)
println("Cleanup finished")
}
}
}
Use this sparingly, because it delays cancellation.
9. Common lifecycle operations
| Operation | Meaning |
|---|---|
job.start() |
Starts a lazy coroutine |
job.join() |
Waits for coroutine completion |
job.cancel() |
Requests cancellation |
job.cancelAndJoin() |
Cancels and waits until finished |
job.isActive |
true while active |
job.isCompleted |
true after completion or cancellation |
job.isCancelled |
true if completed due to cancellation |
10. Practical lifecycle example
import kotlinx.coroutines.*
fun main() = runBlocking {
val job = launch {
try {
println("Started")
repeat(5) { index ->
delay(500)
println("Working $index")
}
println("Finished normally")
} catch (e: CancellationException) {
println("Cancelled")
throw e
} finally {
println("Cleanup")
}
}
delay(1200)
println("Requesting cancellation")
job.cancelAndJoin()
println("Job completed: ${job.isCompleted}")
println("Job cancelled: ${job.isCancelled}")
}
Possible output:
Started
Working 0
Working 1
Requesting cancellation
Cancelled
Cleanup
Job completed: true
Job cancelled: true
Simple way to remember it
Think of a coroutine like this:
Created in a scope
→ runs as a Job
→ may suspend and resume many times
→ finishes successfully, fails, or gets cancelled
→ parent and children affect each other
The key concepts are:
- Scope owns the coroutine
- Job represents its lifecycle
- Suspension is not blocking
- Cancellation is cooperative
- Parents wait for children
- Parent cancellation cancels children
- Child failure usually cancels the parent
