How do I understand the coroutine lifecycle in Kotlin?

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:

  • runBlocking creates a coroutine scope.
  • launch starts a child coroutine.
  • job represents 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:

  • delay
  • withContext
  • yield
  • many kotlinx.coroutines suspending 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:

  1. Scope owns the coroutine
  2. Job represents its lifecycle
  3. Suspension is not blocking
  4. Cancellation is cooperative
  5. Parents wait for children
  6. Parent cancellation cancels children
  7. Child failure usually cancels the parent