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:
launchasyncrunBlocking
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:
- What coroutine builders are
launchand fire-and-forget tasksasyncfor concurrent work producing results- Why
runBlockingshould be used sparingly - Differences in return types (
Job,Deferred<T>,Unit) - Best practices and common mistakes
- 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
| Builder | Returns | When to use |
|---|---|---|
launch | Job | Fire-and-forget background work |
async | Deferred<T> | Concurrent tasks that produce results |
runBlocking | T (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?
launchstarts a new coroutine immediately.- The coroutine runs concurrently with the main
runBlockingcoroutine. 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?
asyncstarts 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 (
asyncstarts 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.
| Builder | Returns | Meaning |
|---|---|---|
launch | Job | A handle to a running coroutine (no result) |
async | Deferred<T> | A future/promise of type T |
runBlocking | T | Returns 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<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:
viewModelScopelifecycleScopecoroutineScopeSupervisorScope
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
coroutineScopeensures 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
