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

How do I cancel a coroutine in Kotlin safely?

In Kotlin, you cancel a coroutine safely by calling cancel() on its Job or scope, and making sure the coroutine code is cooperative: it should suspend regularly or check for cancellation.

Basic example

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        try {
            repeat(1_000) { i ->
                println("Working $i")
                delay(500) // cancellable suspension point
            }
        } finally {
            println("Cleaning up")
        }
    }

    delay(1_500)
    job.cancel()
    job.join()

    println("Cancelled safely")
}

Or more commonly:

job.cancelAndJoin()
job.cancelAndJoin()

This cancels the coroutine and waits until it finishes cleanup.

Use cancellable suspending functions

Most kotlinx.coroutines suspending functions are cancellable, for example:

delay(...)
withContext(...)
receive(...)
send(...)

So this is usually safe:

val job = scope.launch {
    while (true) {
        delay(1000)
        doWork()
    }
}

job.cancel()

For CPU-heavy loops, check cancellation manually

If your coroutine does not suspend often, cancellation will not be noticed immediately.

Use isActive:

val job = scope.launch {
    while (isActive) {
        doCpuWorkChunk()
    }
}

Or call ensureActive():

val job = scope.launch {
    while (true) {
        ensureActive()
        doCpuWorkChunk()
    }
}

Cleanup with finally

Use try/finally for cleanup:

val job = scope.launch {
    try {
        doWork()
    } finally {
        closeResources()
    }
}

If cleanup needs to call suspending functions, use NonCancellable:

val job = scope.launch {
    try {
        doWork()
    } finally {
        withContext(NonCancellable) {
            saveState()
            closeRemoteConnection()
        }
    }
}

Do not swallow CancellationException

Cancellation is represented by CancellationException. Avoid catching it accidentally and ignoring it.

Bad:

try {
    doWork()
} catch (e: Exception) {
    // This also catches CancellationException
    logError(e)
}

Better:

try {
    doWork()
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    logError(e)
}

Cancel a scope

If you created a scope, cancel it when the owner is destroyed:

class Repository {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    fun start() {
        scope.launch {
            doWork()
        }
    }

    fun close() {
        scope.cancel()
    }
}

Summary

Use:

job.cancelAndJoin()

Make your coroutine cooperative by using:

delay(...)
isActive
ensureActive()

Clean up with:

try {
    // work
} finally {
    // cleanup
}

And avoid swallowing CancellationException.

How do I run coroutines in parallel using async and await in Kotlin?

In Kotlin, use async to start concurrent work inside a coroutine scope, then call await() to get each result.

Basic example:

import kotlinx.coroutines.*

suspend fun fetchUser(): String {
    delay(1000)
    return "User"
}

suspend fun fetchPosts(): List<String> {
    delay(1000)
    return listOf("Post 1", "Post 2")
}

fun main() = runBlocking {
    val userDeferred = async {
        fetchUser()
    }

    val postsDeferred = async {
        fetchPosts()
    }

    val user = userDeferred.await()
    val posts = postsDeferred.await()

    println(user)
    println(posts)
}

Here, fetchUser() and fetchPosts() run in parallel. Since both delay for 1 second, the total time is about 1 second instead of 2.

You can also await multiple results with awaitAll:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferredResults = listOf(
        async {
            delay(1000)
            "Result 1"
        },
        async {
            delay(1000)
            "Result 2"
        },
        async {
            delay(1000)
            "Result 3"
        }
    )

    val results = deferredResults.awaitAll()

    println(results)
}

Output:

[Result 1, Result 2, Result 3]

A common pattern is:

coroutineScope {
    val a = async { doWorkA() }
    val b = async { doWorkB() }

    val resultA = a.await()
    val resultB = b.await()

    combine(resultA, resultB)
}

For example:

import kotlinx.coroutines.*

suspend fun loadName(): String {
    delay(500)
    return "Alice"
}

suspend fun loadAge(): Int {
    delay(500)
    return 30
}

suspend fun loadProfile(): String = coroutineScope {
    val name = async { loadName() }
    val age = async { loadAge() }

    "${name.await()} is ${age.await()} years old"
}

fun main() = runBlocking {
    println(loadProfile())
}

Important notes:

  • async returns a Deferred<T>.
  • await() suspends until the result is ready.
  • async should usually be called inside coroutineScope, supervisorScope, runBlocking, or another coroutine.
  • If one async child fails inside coroutineScope, the whole scope is cancelled.
  • Use async for concurrent computations that return values.
  • Use launch for concurrent work that does not return a value.

If you are doing blocking I/O, use an appropriate dispatcher:

val data = async(Dispatchers.IO) {
    blockingNetworkCall()
}

For CPU-heavy work:

val result = async(Dispatchers.Default) {
    heavyCalculation()
}

How do I write a suspend function in Kotlin?

In Kotlin, a suspend function is declared with the suspend modifier.

suspend fun fetchUser(): User {
    // Can call other suspend functions here
    return api.getUser()
}

A suspend function can pause without blocking the thread and later resume. It is commonly used with coroutines for asynchronous work.

Example:

import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

suspend fun greetAfterDelay() {
    delay(1000)
    println("Hello after 1 second")
}

fun main() = runBlocking {
    greetAfterDelay()
}

Key points:

  • Use suspend fun to define one.
  • A suspend function can call other suspend functions.
  • It must be called from:
    • another suspend function, or
    • a coroutine scope such as runBlocking, launch, or async.

Example with return value:

suspend fun loadMessage(): String {
    delay(500)
    return "Loaded message"
}

fun main() = runBlocking {
    val message = loadMessage()
    println(message)
}

So the basic syntax is:

suspend fun functionName() {
    // suspending work
}

How do I use delay instead of Thread.sleep in Kotlin coroutines?

Use kotlinx.coroutines.delay(...) inside a coroutine instead of Thread.sleep(...).

Thread.sleep blocks the current thread. delay suspends the coroutine without blocking the thread, so other coroutines can keep running.

import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    println("Before delay")

    delay(1_000) // suspends for 1 second, does not block the thread

    println("After delay")
}

If you currently have:

Thread.sleep(1000)

replace it with:

delay(1000)

But note: delay is a suspend function, so it can only be called from another suspend function or from inside a coroutine builder such as launch, async, or runBlocking.

Example with launch:

import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    launch {
        delay(1000)
        println("Coroutine finished after 1 second")
    }

    println("This prints immediately")
}

Example in a suspend function:

import kotlinx.coroutines.delay

suspend fun doWork() {
    delay(500)
    println("Work done")
}

Avoid doing this in coroutines:

Thread.sleep(1000) // blocks the underlying thread

Prefer:

delay(1000) // suspends only the coroutine