Coroutine Builders: launch, async, runBlocking

Kotlin Coroutines have become one of the most important tools for writing concurrent, asynchronous, and non-blocking code in the Kotlin ecosystem. While understanding suspending functions and coroutine scopes is important, the real entry point to coroutine programming is learning the three primary coroutine builders:

  • launch
  • async
  • runBlocking

These builders define how coroutines are created, how they behave, and how their results are returned.
In this article, we’ll dive deep into what coroutine builders are, how each of them works, what their differences are, and when you should (or should not) use each one.

We will walk step-by-step through:

  1. What coroutine builders are
  2. launch and fire-and-forget tasks
  3. async for concurrent work producing results
  4. Why runBlocking should be used sparingly
  5. Differences in return types (Job, Deferred<T>, Unit)
  6. Best practices and common mistakes
  7. Example: Running parallel API calls

Let’s begin.


What Are Builders?

Coroutine builders are Kotlin functions that create and start new coroutines. Think of them like:

  • Thread constructors (Thread { ... }.start())
  • Promise creators in JavaScript
  • Task schedulers in .NET

But with more structure and much lighter runtime cost.

Why do we need builders?

Because a coroutine doesn’t exist until it is built and attached to a scope. Builders define:

  • How a coroutine starts
  • What it returns
  • Whether it blocks or suspends
  • How it handles errors
  • Which context it runs in

The three main coroutine builders

BuilderReturnsWhen to use
launchJobFire-and-forget background work
asyncDeferred<T>Concurrent tasks that produce results
runBlockingT (blocks thread)Main function, tests, learning

Kotlin also has advanced builders like produce, actor, coroutineScope, etc., but the three above cover 95% of real-world usage.


launch and Fire-and-Forget Tasks

launch creates a coroutine that does not return a result. It returns a Job, which represents:

  • A reference to the coroutine
  • Its lifecycle
  • Whether it is cancelled or finished
  • An optional way to wait for it (join())

Think of launch as “start this task in the background.”

Example:

import kotlinx.coroutines.*

fun main() = runBlocking {
    val job = launch {
        println("Doing work...")
        delay(1000)
        println("Work complete!")
    }

    println("Waiting...")
    job.join() // Wait until the coroutine finishes
    println("Done")
}

What does this do?

  1. launch starts a new coroutine immediately.
  2. The coroutine runs concurrently with the main runBlocking coroutine.
  3. job.join() suspends the parent coroutine until the job completes.

When to use launch

Use launch when:

  • You don’t need a return value
  • You’re performing tasks like:
    • Writing to a database
    • Sending analytics
    • Saving a file
    • Logging events
  • You want operations that operate independently

Why “fire-and-forget”?

Because once it starts, you don’t have to wait for it:

scope.launch {
    sendAnalyticsEvent()
}

Even if you don’t capture the Job, the work still runs.

Advanced: Cancelling a launched coroutine

val job = launch {
    longRunningTask()
}

delay(500)
job.cancel()

cancel() does not force immediate termination—it cooperatively cancels at the next suspension point (delay, network call, etc.).


async for Concurrent Deferred Tasks

While launch is for tasks you don’t care about returning results, async is designed for tasks that produce a value.

When you call async, you receive a Deferred<T> object, essentially “a promise of a future value.”

You retrieve the result using await().

Example: Basic async usage

fun main() = runBlocking {
    val deferred = async {
        delay(1000)
        42
    }

    println("Waiting...")
    val result = deferred.await()
    println("Result = $result")
}

What’s happening?

  • async starts a coroutine immediately.
  • It returns Deferred<Int>.
  • await() suspends until the computation finishes.

async is for concurrency, not parallelism

Concurrency means tasks can be interwoven on the same thread; parallelism means running on multiple threads. Both are possible depending on dispatcher.

Common use: Run tasks in parallel

val userDeferred = async { fetchUser() }
val postsDeferred = async { fetchPosts() }

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

Instead of waiting for one network request to finish before starting the next, both run at the same time.

Important: async also has a lazy mode

val deferred = async(start = CoroutineStart.LAZY) {
    computeSomething()
}

deferred.start()
val result = deferred.await()

Useful when you don’t want immediate execution.

When to use async

  • Multiple network/database calls that can run concurrently
  • CPU computations
  • Independent calculations
  • Parallel API calls

When NOT to use async

  • When the result is not needed → use launch
  • When you want structured concurrency inside a suspend function
  • When a task shouldn’t start immediately (async starts instantly unless lazy)

When and Why Not to Use runBlocking

runBlocking is the most misunderstood coroutine builder.

Unlike launch and async, which suspend, runBlocking blocks the current thread.

It is primarily designed for:

  • Running coroutines in main()
  • Learning or quick experiments
  • Writing unit tests

DO NOT use runBlocking inside production code like:

❌ Android UI (will freeze the UI thread)
❌ Backend request handlers
❌ Repositories
❌ ViewModels
❌ Services

Example of misuse on Android:

button.setOnClickListener {
    runBlocking {
        // Freezes the UI!!
        delay(2000)
    }
}

This blocks the main thread for 2 seconds → ANR risk.

Good example: Main function

fun main() = runBlocking {
    println("Starting...")
    delay(1000)
    println("Done!")
}

Good example: Unit tests

@Test
fun testSomething() = runBlocking {
    val result = fetchData()
    assertEquals(42, result)
}

Why not use runBlocking everywhere?

  • It blocks threads, defeating the purpose of coroutines
  • It ignores structured concurrency principles
  • It can cause deadlocks
  • It can degrade performance

Differences in Return Types

Understanding return types helps you choose the correct builder.

BuilderReturnsMeaning
launchJobA handle to a running coroutine (no result)
asyncDeferred<T>A future/promise of type T
runBlockingTReturns the final result of the block after blocking

What is a Job?

A Job represents a coroutine’s lifecycle:

val job: Job = launch {
    doWork()
}
job.cancel()
job.join()

What is Deferred<T>?

A Deferred<T> is a Job that contains a result.

val deferred: Deferred&lt;Int> = async { computeValue() }
val result: Int = deferred.await()

It also supports cancellation like a Job.

Why not use async if you don’t need a result?

Because:

  • async → costly, must be awaited
  • launch → simpler, more idiomatic

Best Practices and Common Mistakes

Here’s a collection of beginner pitfalls and how to avoid them.


Mistake 1: Using runBlocking inside suspend functions

❌ Wrong:

suspend fun loadData() {
    runBlocking {
        delay(1000)
    }
}

This blocks the thread inside a suspend function — never do this.


Mistake 2: Using async without await

async { compute() } // result lost

You’re starting a concurrent task but never using the result.
If the result isn’t needed → use launch.


Mistake 3: async inside launch when result is needed

❌ Wrong:

launch {
    val r = async { compute() }
    println(r.await()) // okay but unnecessary
}

Better:

val r = coroutineScope { compute() }
println(r)


Mistake 4: Calling await() sequentially

❌ Poor performance:

val a = async { call1() }
val b = async { call2() }

val r1 = a.await() // OK
val r2 = b.await() // OK

But calling await inside the async block is wrong:

val r = async {
    val r1 = call1()
    val r2 = call2()
    r1 + r2
}.await()

This removes concurrency.


Mistake 5: Launching coroutines in GlobalScope

Avoid this except for top-level app services.

Better: use structured scopes:

  • viewModelScope
  • lifecycleScope
  • coroutineScope
  • SupervisorScope

Example: Running Parallel API Calls

Let’s build a realistic example: fetching user info and posts concurrently.

Before (sequential, slower)

suspend fun loadUserAndPosts(): Pair<User, List<Post>> {
    val user = fetchUser()
    val posts = fetchPosts(user.id)
    return user to posts
}

This takes time:

  • fetchUser = 1 second
  • fetchPosts = 1 second

Total = 2 seconds

After (parallel, faster)

suspend fun loadUserAndPosts(): Pair<User, List<Post>> = coroutineScope {
    val userDeferred = async { fetchUser() }
    val postsDeferred = async { fetchPosts() }

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

    user to posts
}

Now both calls start immediately.
Total time ≈ 1 second.

Explanation

  • coroutineScope ensures structured concurrency
  • async kicks off two tasks in parallel
  • await suspends instead of blocking
  • user and posts collected together

Improved version with dependencies

If posts depend on user:

suspend fun loadUserAndTheirPosts(): Pair<User, List<Post>> = coroutineScope {
    val userDeferred = async { fetchUser() }
    val postsDeferred = async {
        val user = userDeferred.await()
        fetchPosts(user.id)
    }

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

    user to posts
}


Conclusion

Understanding coroutine builders is essential to mastering Kotlin’s concurrency model.

You learned:

  • What builders are → functions that start coroutines
  • launch → fire-and-forget background tasks
  • async → concurrent work that returns results
  • runBlocking → blocks a thread; avoid in production
  • Return types → Job, Deferred<T>, regular values
  • Best practices
  • How to run parallel API calls

Leave a Reply

Your email address will not be published. Required fields are marked *