In the previous tutorial, you learned about sequences. Now let’s learn about coroutines. Coroutines are Kotlin’s way of writing asynchronous code. They let you write code that looks synchronous but runs without blocking threads.

In this tutorial, you will learn:

  • Suspend functions
  • launch — fire and forget
  • async/await — get a result
  • Dispatchers
  • coroutineScope
  • Structured concurrency
  • Jobs and cancellation
  • Timeouts
  • Practical examples

What Are Coroutines?

A coroutine is a lightweight thread. You can run thousands of coroutines on a single thread. Unlike threads, coroutines are cheap to create and do not block the operating system thread they run on.

Regular functions run from start to finish without stopping. Coroutines can pause (suspend) and resume later. While a coroutine is suspended, the thread is free to do other work.

// Regular function — blocks the thread
fun fetchData(): String {
    Thread.sleep(1000) // Blocks the thread for 1 second
    return "data"
}

// Suspend function — suspends without blocking
suspend fun fetchData(): String {
    delay(1000) // Suspends for 1 second, thread is free
    return "data"
}

Setting Up Coroutines

Add the coroutines dependency to your build.gradle.kts:

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}

Suspend Functions

A suspend function is a function that can pause and resume. You mark it with the suspend keyword. You can only call a suspend function from another suspend function or from a coroutine.

suspend fun fetchUserName(): String {
    delay(100) // Simulates a network call
    return "Alex"
}

suspend fun fetchUserAge(): Int {
    delay(100) // Simulates another network call
    return 28
}

Suspend functions can call other suspend functions:

suspend fun fetchUserProfile(): String {
    val name = fetchUserName()
    val age = fetchUserAge()
    return "$name, age $age"
}

In this example, fetchUserName() runs first, then fetchUserAge() runs. They run sequentially. The total time is about 200ms (100ms + 100ms). Later, you will learn how to run them in parallel with async.

runBlocking — The Bridge

runBlocking creates a coroutine and blocks the current thread until it finishes. Use it in main() functions or tests to call suspend functions.

fun main() = runBlocking {
    val profile = fetchUserProfile()
    println(profile) // "Alex, age 28"
}

Do not use runBlocking in production code. It blocks the thread, which defeats the purpose of coroutines. Use it only in main() or tests.

launch — Fire and Forget

launch starts a new coroutine and does not return a result. It returns a Job that you can use to cancel or wait for the coroutine.

Use launch when you don’t need a result back.

coroutineScope {
    launch {
        delay(100)
        println("Task 1 done")
    }

    launch {
        delay(200)
        println("Task 2 done")
    }

    println("Both tasks started")
}
println("Both tasks finished")

Output:

Both tasks started
Task 1 done
Task 2 done
Both tasks finished

Both coroutines start right away. coroutineScope waits for both to finish before continuing.

async/await — Get a Result

async starts a new coroutine and returns a Deferred<T>. Call .await() to get the result.

Use async when you need a result back.

coroutineScope {
    val nameDeferred: Deferred<String> = async {
        delay(100)
        "Alex"
    }

    val ageDeferred: Deferred<Int> = async {
        delay(100)
        28
    }

    // Both run in parallel — total time ~100ms, not 200ms
    val name = nameDeferred.await()
    val age = ageDeferred.await()
    println("$name is $age years old")
}

The key difference: launch is for side effects (logging, saving to database). async is for computations that return a value.

launch vs async

Featurelaunchasync
ReturnsJobDeferred<T>
Gets resultNoYes, via .await()
Use caseSide effectsComputations
ExceptionPropagates immediatelyPropagates on .await()

Parallel Decomposition

The most common use case for async is running multiple tasks in parallel and combining their results:

suspend fun fetchFromMultipleSources(): List<String> {
    return coroutineScope {
        val source1 = async { delay(100); listOf("Item A", "Item B") }
        val source2 = async { delay(150); listOf("Item C", "Item D") }
        val source3 = async { delay(80); listOf("Item E") }

        source1.await() + source2.await() + source3.await()
    }
}

All three sources fetch data in parallel. The total time is about 150ms (the slowest one), not 330ms (100 + 150 + 80).

Dispatchers

Dispatchers control which thread a coroutine runs on. Kotlin provides several built-in dispatchers:

DispatcherThreadUse Case
Dispatchers.DefaultShared thread poolCPU-intensive work (sorting, parsing)
Dispatchers.IOShared thread pool (larger)I/O operations (network, files)
Dispatchers.MainMain/UI threadUI updates (Android only)
Dispatchers.UnconfinedCaller threadTesting, special cases
coroutineScope {
    launch(Dispatchers.Default) {
        println("Default: ${Thread.currentThread().name}")
    }

    launch(Dispatchers.IO) {
        println("IO: ${Thread.currentThread().name}")
    }
}

withContext

withContext switches the dispatcher for a block of code. It suspends the current coroutine and runs the block on the given dispatcher.

suspend fun computeResult(): String {
    return withContext(Dispatchers.Default) {
        // Heavy computation on Default dispatcher
        (1..1_000_000).filter { it % 2 == 0 }.sum().toString()
    }
}

Use withContext when you need to switch dispatchers within a function. For example, fetch data on Dispatchers.IO and then process it on Dispatchers.Default.

coroutineScope

coroutineScope creates a new scope and waits for all children to finish. If any child fails, all other children are cancelled.

coroutineScope {
    launch {
        delay(100)
        println("Child 1 done")
    }
    launch {
        delay(200)
        println("Child 2 done")
    }
    println("Parent continues while children run")
}
println("All children finished")

coroutineScope is a suspend function, so it does not block the thread. It just suspends until all children are done.

Structured Concurrency

Structured concurrency is a design principle that makes coroutine management safe and predictable. It has four rules:

  1. Every coroutine has a parent scope. You cannot create a coroutine without a scope.
  2. Parent waits for all children. The parent coroutine does not complete until all its children complete.
  3. Parent cancellation cancels children. If the parent is cancelled, all children are automatically cancelled.
  4. Child failure cancels parent and siblings. If a child throws an exception, the parent and all other children are cancelled.
coroutineScope {
    val job1 = launch {
        delay(100)
        println("Job 1 done")
    }
    val job2 = launch {
        delay(200)
        println("Job 2 done")
    }
    val result = async {
        delay(150)
        42
    }

    println("Result: ${result.await()}")
    // job1 and job2 are automatically waited for
}
println("Everything is done")

Without structured concurrency, you would have to track every coroutine manually and make sure they all finish or get cancelled. Structured concurrency handles this for you.

Job

launch returns a Job. You can use it to control the coroutine:

coroutineScope {
    val job = launch {
        repeat(5) { i ->
            println("Working $i...")
            delay(100)
        }
    }

    delay(250) // Let it run for a bit
    println("Cancelling job...")
    job.cancel()
    job.join() // Wait for cancellation to complete
    println("Job cancelled: ${job.isCancelled}") // true
}

Job properties:

PropertyDescription
isActiveIs the coroutine still running?
isCompletedHas the coroutine finished?
isCancelledWas the coroutine cancelled?

Job functions:

FunctionDescription
cancel()Cancel the coroutine
join()Wait for the coroutine to finish
cancelAndJoin()Cancel and wait

Cancellation

Cancellation in coroutines is cooperative. The coroutine must check for cancellation. All suspend functions in kotlinx.coroutines (like delay, yield) check for cancellation automatically.

For CPU-intensive work without suspend points, use isActive or ensureActive():

val job = launch {
    var i = 0
    while (isActive) { // Check if coroutine is still active
        i++
        // CPU-intensive work...
    }
    println("Loop ended at $i")
}

delay(10)
job.cancel()
job.join()

Cleanup with try-finally

Use try-finally to clean up resources when a coroutine is cancelled:

val job = launch {
    try {
        repeat(100) { i ->
            println("Processing $i...")
            delay(50)
        }
    } finally {
        // This always runs, even on cancellation
        println("Cleaning up resources...")
    }
}

delay(130)
job.cancel()
job.join()

The finally block runs even when the coroutine is cancelled. This is where you close files, release connections, or do other cleanup work.

Non-cancellable Block

If you need to call a suspend function in the finally block, use withContext(NonCancellable):

finally {
    withContext(NonCancellable) {
        delay(100) // This would fail without NonCancellable
        println("Saved progress to database")
    }
}

Timeouts

withTimeout cancels the coroutine if it takes too long. It throws TimeoutCancellationException:

try {
    withTimeout(200) {
        delay(500) // Takes too long
        println("Done")
    }
} catch (e: TimeoutCancellationException) {
    println("Timed out!")
}

withTimeoutOrNull returns null instead of throwing an exception:

val result = withTimeoutOrNull(200) {
    delay(500)
    "Done"
}
println(result) // null

val result2 = withTimeoutOrNull(200) {
    delay(100)
    "Done"
}
println(result2) // "Done"

Use withTimeoutOrNull when a timeout is a normal case, not an error.

Practical Example: Fetch User Data

Here is a real-world pattern. Fetch data from multiple sources in parallel:

data class UserData(
    val name: String,
    val posts: List<String>,
    val followers: Int
)

suspend fun fetchUserData(userId: String): UserData {
    return coroutineScope {
        val name = async { fetchUserName(userId) }
        val posts = async { fetchUserPosts(userId) }
        val followers = async { fetchFollowerCount(userId) }

        UserData(
            name = name.await(),
            posts = posts.await(),
            followers = followers.await()
        )
    }
}

All three API calls run in parallel. If any one fails, the others are automatically cancelled (structured concurrency).

Practical Example: Process Items Concurrently

Process a list of items in parallel:

suspend fun processItemsConcurrently(items: List<String>): List<String> {
    return coroutineScope {
        items.map { item ->
            async {
                delay(50) // Simulate processing
                item.uppercase()
            }
        }.map { it.await() }
    }
}

// Usage
val result = processItemsConcurrently(listOf("hello", "world", "kotlin"))
println(result) // [HELLO, WORLD, KOTLIN]

All items are processed at the same time. The total time is about 50ms (one delay), not 150ms (3 x 50ms).

Practical Example: Retry with Delay

Retry a failing operation with exponential backoff:

suspend fun <T> retryWithDelay(
    times: Int = 3,
    initialDelay: Long = 100,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(times - 1) { attempt ->
        try {
            return block()
        } catch (e: Exception) {
            println("Attempt ${attempt + 1} failed: ${e.message}")
        }
        delay(currentDelay)
        currentDelay = (currentDelay * factor).toLong()
    }
    return block() // Last attempt
}

The delay doubles after each failure: 100ms, 200ms, 400ms. This is a common pattern for network retries.

Common Mistakes

Mistake 1: Using runBlocking in Production

// BAD — blocks the thread
fun getData(): String = runBlocking {
    fetchData()
}

// GOOD — keep it suspend
suspend fun getData(): String {
    return fetchData()
}

Mistake 2: Forgetting Structured Concurrency

// BAD — coroutine leak, no parent scope
GlobalScope.launch {
    doSomething()
}

// GOOD — use coroutineScope or a lifecycle-aware scope
coroutineScope {
    launch {
        doSomething()
    }
}

Mistake 3: Sequential When Parallel is Better

// SLOW — runs sequentially (200ms)
suspend fun fetchBoth(): Pair<String, Int> {
    val name = fetchUserName() // 100ms
    val age = fetchUserAge()   // 100ms
    return Pair(name, age)
}

// FAST — runs in parallel (100ms)
suspend fun fetchBoth(): Pair<String, Int> = coroutineScope {
    val name = async { fetchUserName() }
    val age = async { fetchUserAge() }
    Pair(name.await(), age.await())
}

Summary

ConceptDescription
suspendMarks a function that can pause and resume
launchStarts a coroutine, returns Job (no result)
asyncStarts a coroutine, returns Deferred<T> (with result)
runBlockingBlocks thread until coroutine finishes
coroutineScopeCreates scope, waits for all children
DispatchersControls which thread the coroutine runs on
withContextSwitches dispatcher for a block
JobControls and monitors a coroutine
cancel()Cancels a coroutine (cooperative)
withTimeoutCancels if too slow (throws)
withTimeoutOrNullCancels if too slow (returns null)

Source Code

You can find the source code for this tutorial on GitHub: tutorial-17-coroutines

What’s Next?

In the next tutorial, you will learn about advanced coroutine topics: error handling with CoroutineExceptionHandler, SupervisorJob, channels, and more.