Coroutines in Kotlin: launch, async, and scope

1. Overview

Coroutines are how Kotlin handles asynchronous code without callback hell or blocking threads. This article explains how launch, async, suspend fun, and CoroutineScope work, and when to use each. After reading, you’ll be able to write clean concurrent code, know how to control a coroutine’s lifecycle, and test it with JUnit 5.

2. Required Dependencies

Coroutines are not built into the Kotlin standard library; you need to add kotlinx-coroutines-core.

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>1.8.1</version>
</dependency>
<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-test</artifactId>
    <version>1.8.1</version>
    <scope>test</scope>
</dependency>

If you’re using Spring Boot, the spring-boot-starter-webflux or spring-boot-starter module already includes Kotlin extensions that support suspend fun in controllers.

3. suspend fun: the basic unit of coroutines

suspend is a keyword marking a function that can be suspended without blocking the thread.

suspend fun fetchOrder(orderId: Long): Order {
    delay(200) // simulate I/O call, doesn't actually block the thread
    return Order(orderId, "PENDING")
}

data class Order(val id: Long, val status: String)

delay() is a built-in suspend function, unlike Thread.sleep() because it doesn’t hold the thread while waiting. suspend fun can only be called from another suspend fun or from within a coroutine builder (launch, async, runBlocking).

Note: calling a suspend fun directly from a regular fun (non-suspend) will cause a compile error. This is Kotlin’s mechanism forcing you to explicitly declare the asynchronous context.

4. launch: fire-and-forget coroutine

launch creates a new coroutine and returns a Job; it doesn’t return a computed result—use it when you just need to “fire and forget”.

fun main() = runBlocking {
    val job: Job = launch {
        println("Starting order processing on thread: ${Thread.currentThread().name}")
        val order = fetchOrder(1L)
        println("Order status: ${order.status}")
    }
    println("launch has returned, coroutine runs in parallel")
    job.join() // wait for the coroutine to complete
}
launch has returned, coroutine runs in parallel
Starting order processing on thread: main
Order status: PENDING

runBlocking creates a scope to run coroutines in main (only use it for demos/tests, not in real production code). launch doesn’t block the current thread—it schedules the coroutine to run and returns immediately. job.join() is where you wait for that coroutine to complete.

5. async: coroutines that return results

async is like launch but returns Deferred<T>—a “promise” that you call .await() on to get the result.

suspend fun fetchCustomer(customerId: Long): String {
    delay(150)
    return "Customer-$customerId"
}

suspend fun loadOrderDetail(orderId: Long, customerId: Long): String = coroutineScope {
    val orderDeferred = async { fetchOrder(orderId) }
    val customerDeferred = async { fetchCustomer(customerId) }

    val order = orderDeferred.await()
    val customer = customerDeferred.await()
    "Order ${order.id} (${order.status}) for $customer"
}

The two async calls run in parallel (concurrently), not sequentially. The total wait time here is ~200ms (the max of 200ms and 150ms), not 350ms. .await() is where you suspend to get the result—if you call .await() immediately after each async instead of separating them, the code will run sequentially and lose the parallelism benefit.

// Wrong: runs sequentially, defeats the purpose of async
val order = async { fetchOrder(orderId) }.await()
val customer = async { fetchCustomer(customerId) }.await()

Note: the code above has no syntax error but defeats the purpose of using async—each .await() blocks right after creation, so the two coroutines never run concurrently.

6. CoroutineScope: who manages a coroutine’s lifecycle

Every coroutine must belong to a CoroutineScope. The scope determines: how long the coroutine lives, when it’s cancelled, and how exceptions propagate.

class OrderService {
    private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())

    fun processOrderAsync(orderId: Long) {
        serviceScope.launch {
            val order = fetchOrder(orderId)
            println("Processed order ${order.id}")
        }
    }

    fun shutdown() {
        serviceScope.cancel() // cancel all child coroutines running in this scope
    }
}

serviceScope is kept as a class field—this is how you avoid “orphaned” coroutines running forever after the object is no longer used. SupervisorJob() ensures that one child coroutine failing doesn’t cancel the other child coroutines (unlike a regular Job(), where one child failing cancels all siblings).

6.1. coroutineScope vs CoroutineScope

Don’t confuse coroutineScope { ... } (a suspend function, lowercase) with CoroutineScope(...) (a constructor that creates a new scope, uppercase).

suspend fun loadDashboard(): String = coroutineScope {
    // coroutineScope waits for ALL child coroutines inside to complete
    // then returns, and it uses the context of the parent coroutine
    val a = async { fetchOrder(1L) }
    val b = async { fetchCustomer(2L) }
    "${a.await()} - ${b.await()}"
}

coroutineScope is a suspend function builder used to group multiple child coroutines, ensuring structured concurrency: the function only returns when all async/launch calls inside complete (or one of them fails and all are cancelled). It doesn’t create a new independent scope but inherits the current context—very different from CoroutineScope(...) in section 6, which creates a scope that lives independently and isn’t automatically cancelled when the function ends.

7. Dispatcher: which thread does the coroutine run on?

Dispatcher determines where a coroutine runs—similar to choosing an Executor in Java.

suspend fun readConfigFile(path: String): String = withContext(Dispatchers.IO) {
    // Dispatchers.IO is for blocking I/O (reading files, calling JDBC...)
    File(path).readText()
}

suspend fun heavyCompute(data: List<Int>): Long = withContext(Dispatchers.Default) {
    // Dispatchers.Default is for CPU-bound work
    data.sumOf { it.toLong() * it }
}

withContext switches the current coroutine to run on a different dispatcher and automatically switches back after it completes—very useful when you need to call blocking code (JDBC, file I/O) without tying up the main thread of Dispatchers.Default or Dispatchers.Main (on Android).

Dispatcher Use for
Dispatchers.Default CPU-bound computation (parsing, sorting, computing)
Dispatchers.IO Blocking I/O (files, JDBC, blocking network)
Dispatchers.Main UI updates (Android/JavaFX only)
Dispatchers.Unconfined Rarely used, runs on the initial calling thread

8. Exception handling in coroutines

Exceptions in launch and async propagate differently—this is the most common source of bugs.

fun main() = runBlocking {
    // launch: exception is thrown immediately and can crash the scope if not caught
    val job = launch {
        try {
            throw IllegalStateException("Order does not exist")
        } catch (e: IllegalStateException) {
            println("Caught error in launch: ${e.message}")
        }
    }
    job.join()

    // async: exception is kept in the Deferred and only thrown when await() is called
    val deferred = async {
        throw IllegalStateException("Customer does not exist")
    }
    try {
        deferred.await()
    } catch (e: IllegalStateException) {
        println("Caught error in async: ${e.message}")
    }
}
Caught error in launch: Order does not exist
Caught error in async: Customer does not exist

With async, if you forget to call .await(), the exception will silently be “swallowed” until the Deferred is garbage-collected or you call .await() somewhere—very easy to create hard-to-debug bugs in production.

9. Writing tests for coroutines

Use kotlinx-coroutines-test with runTest to test suspend functions without waiting for delay to actually complete.

class OrderServiceTest {

    @Test
    fun `fetchOrder returns status PENDING`() = runTest {
        val order = fetchOrder(1L)

        assertThat(order.id).isEqualTo(1L)
        assertThat(order.status).isEqualTo("PENDING")
    }

    @Test
    fun `loadOrderDetail runs two coroutines in parallel`() = runTest {
        val result = loadOrderDetail(orderId = 1L, customerId = 2L)

        assertThat(result).contains("Order 1")
        assertThat(result).contains("Customer-2")
    }
}

runTest automatically uses virtual time—delay(200) inside fetchOrder doesn’t make the test actually run slow by 200ms, it skips right to the resume point. This is why you shouldn’t test coroutines with runBlocking in unit tests: it waits for real time, and your test suite gets progressively slower as you add more tests.

10. Common mistakes

  • Using GlobalScope: coroutines live for the entire application lifetime and aren’t cancelled by any lifecycle—causes leaks. Always create a scope tied to a specific class/component as shown in section 6.
  • Calling runBlocking in production code: it blocks the current thread for real, defeating the purpose of coroutines. Only use it in main(), tests, or where you must bridge blocking code with suspend code.
  • Forgetting .await() on async: exceptions are swallowed, coroutines run without anyone waiting for results—wasteful and hard to trace bugs.
  • Mixing Dispatchers.IO for CPU-bound work: Dispatchers.IO has a much larger thread pool (optimized for I/O waiting), using it for heavy computation degrades scheduling efficiency.

Note: if your service doesn’t need real concurrency (just calling a few I/Os sequentially), coroutines might be over-engineering. Sometimes a simple sequential suspend fun call, without async/launch, is clearer and easier to maintain.

11. Conclusion

suspend fun is the foundation, launch is used when you don’t need a result, async is used when you need to run in parallel and get results via .await(). CoroutineScope determines the lifecycle—always tie the scope to a specific component, avoid GlobalScope, and prefer coroutineScope/SupervisorJob for structured concurrency control. Mastering these three concepts is enough to write most asynchronous code in Kotlin safely and testably.

kotlincoroutinesconcurrencyandroidspring-boot