launch and async

Kotlin coroutines give you two main tools for starting concurrent work: launch and async. Both start a new coroutine that runs concurrently with the code around it, but they hand back different things and are meant for different jobs. Getting this distinction right is the single most important step in writing coroutine code that is both correct and actually concurrent, rather than code that merely looks concurrent while running sequentially by accident.

Overview: How launch and async Work

Both launch and async are coroutine builders — functions that create and immediately start a new coroutine on a CoroutineScope. They cannot be called from arbitrary code; they need a scope, which is why almost every example wraps things in runBlocking (a builder that blocks the current thread until its coroutine, and all of its children, finish — useful for main functions and tests, but rarely used in real application code, where you get a scope from a framework like Android’s viewModelScope).

launch is for fire-and-forget work: you want the code to run, but you don’t need a value back. It returns a Job, a handle you can use to cancel the coroutine or wait for it to finish (with job.join()), but a Job carries no result.

async is for work that produces a value. It returns a Deferred<T>, which is a Job that also holds a future result of type T. You retrieve that result by calling the suspending function await(), which suspends the caller until the coroutine completes and then returns its value (or rethrows its exception, if it failed).

The key thing to understand about how this works under the hood is that neither builder blocks a thread while it waits. A suspending function like delay() or await() saves its continuation (essentially, “where to resume and with what local state”) and gives the underlying thread back to the coroutine dispatcher, which can run other coroutines on it. When the awaited work finishes, the dispatcher resumes the continuation, possibly on the same thread, possibly on another one from its pool. This is fundamentally different from a blocked OS thread, which sits idle holding its stack and cannot do anything else until it is unblocked. That’s why a single thread can juggle thousands of coroutines but only a handful of blocked threads.

Both builders also participate in structured concurrency: a coroutine started with launch or async becomes a child of the scope it was launched in. The parent scope (for example, runBlocking) will not complete until all of its children complete, and if a child fails with an exception, that failure propagates up and cancels its siblings by default. This is why you never see “leaked” background coroutines in well-structured Kotlin code — the compiler and runtime enforce that every coroutine has an owner.

Syntax

The general shape of each builder:

// Fire-and-forget: returns a Job, no result value
val job = launch {
    // suspendable code here
}

// Produces a value: returns a Deferred<T>
val deferred = async {
    // suspendable code here, last expression is the result
    42
}
val result: Int = deferred.await()
Builder Returns Use when…
launch Job You want a side effect (write to a file, update UI, log something) and don’t need a return value.
async Deferred<T> You need to compute a value concurrently with other work, then combine the results with await().

Examples

Example 1: basic launch. This example shows that launch starts a coroutine and returns control immediately — the code after launch keeps running without waiting for it.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Start: ${Thread.currentThread().name}")
    launch {
        delay(1000L)
        println("Coroutine finished: ${Thread.currentThread().name}")
    }
    println("End of main coroutine body")
}

Output:

Start: main
End of main coroutine body
Coroutine finished: main

“End of main coroutine body” prints before “Coroutine finished” because launch doesn’t wait for its body — it schedules the coroutine and returns immediately. runBlocking, however, will not let main exit until that launched child completes, which is why the final line still appears before the program ends.

Example 2: async and await. Here two computations run concurrently and their results are combined once both are ready.

import kotlinx.coroutines.*

fun main() = runBlocking {
    val deferredA: Deferred<Int> = async {
        delay(500L)
        10
    }
    val deferredB: Deferred<Int> = async {
        delay(300L)
        20
    }
    val sum = deferredA.await() + deferredB.await()
    println("Sum: $sum")
}

Output:

Sum: 30

Both async blocks start right away, so the 500ms and 300ms delays overlap — the whole program takes about 500ms, not 800ms. Calling await() on deferredA first only suspends until that value is ready; by the time it resumes, deferredB‘s 300ms wait has usually already finished in the background.

Example 3: a more realistic case — fetching several items concurrently.

import kotlinx.coroutines.*

data class UserProfile(val id: Int, val name: String)

suspend fun fetchProfile(id: Int): UserProfile {
    delay(200L)
    return UserProfile(id, "User$id")
}

fun main() = runBlocking {
    val ids = listOf(1, 2, 3)
    val deferredProfiles = ids.map { id -> async { fetchProfile(id) } }
    val profiles = deferredProfiles.awaitAll()
    profiles.forEach { println(it) }
}

Output:

UserProfile(id=1, name=User1)
UserProfile(id=2, name=User2)
UserProfile(id=3, name=User3)

Each id kicks off its own async coroutine, so all three simulated network calls overlap and the whole batch takes roughly 200ms instead of 600ms sequential. UserProfile is a data class, so its auto-generated toString() is what produces the readable UserProfile(id=1, name=User1) output above.

How It Works Step by Step

Walking through Example 2 in order:

  1. runBlocking starts a coroutine and blocks the calling thread until that coroutine (and all its children) finish.
  2. async { ... } for deferredA creates a child coroutine and schedules it; execution of the runBlocking body continues immediately, it does not wait.
  3. async { ... } for deferredB does the same — now two child coroutines are both pending, effectively running side by side.
  4. deferredA.await() suspends the runBlocking coroutine until deferredA‘s body completes and produces 10. While suspended, the thread is free, so deferredB‘s coroutine can keep progressing on it.
  5. deferredB.await() suspends again if deferredB isn’t done yet, or returns immediately with 20 if it already finished during the first suspension.
  6. The sum is computed and printed, then runBlocking returns because it has no more pending children.

Common Mistakes

Mistake 1: awaiting immediately after each async, which cancels the concurrency.

import kotlinx.coroutines.*

suspend fun fetchA(): Int { delay(300L); return 1 }
suspend fun fetchB(): Int { delay(300L); return 2 }

fun main() = runBlocking {
    val a = async { fetchA() }.await() // suspends here...
    val b = async { fetchB() }.await() // ...before b even starts
    println(a + b)
}

Calling .await() right after each async forces the program to wait for fetchA to fully finish before fetchB even starts, so the two 300ms delays run one after another (~600ms total). This defeats the entire point of using async — it behaves no better than sequential code, just with extra overhead.

import kotlinx.coroutines.*

suspend fun fetchA(): Int { delay(300L); return 1 }
suspend fun fetchB(): Int { delay(300L); return 2 }

fun main() = runBlocking {
    val deferredA = async { fetchA() } // starts immediately
    val deferredB = async { fetchB() } // also starts immediately
    println(deferredA.await() + deferredB.await()) // now wait for both
}

Starting both coroutines first, and only awaiting afterward, lets the two delays overlap so the total time is about 300ms.

Mistake 2: calling a blocking builder like runBlocking from inside suspending code.

import kotlinx.coroutines.*

suspend fun loadData(): String {
    return runBlocking { // wrong: blocks the underlying thread
        delay(100L)
        "data"
    }
}

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

loadData is already a suspend function, so nesting runBlocking inside it is unnecessary and harmful: runBlocking parks the real thread until its block finishes, which can starve the dispatcher’s thread pool and even deadlock if that thread is needed to resume other coroutines. Just use delay directly, since the enclosing function is already suspending.

import kotlinx.coroutines.*

suspend fun loadData(): String {
    delay(100L)
    return "data"
}

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

Best Practices

  • Use launch for side-effecting work where you don’t need a result; use async only when you actually need the value it produces.
  • Start every async you plan to combine before calling await() on any of them, so their work overlaps.
  • Prefer awaitAll() over a list of individual .await() calls in a loop — it reads clearer and fails fast if any deferred throws.
  • Never call a blocking builder such as runBlocking from inside a suspend function or another coroutine — only use it at the true entry point (a main function or a test).
  • Let structured concurrency work for you: launch coroutines in a scope tied to the lifetime that owns them (a request, a screen, a test) instead of a global, unscoped launcher, so failures and cancellation propagate correctly.
  • Reserve Deferred for values you actually intend to await; a fire-and-forget async whose result is discarded should be a launch instead, since an un-awaited exception can behave differently from an un-joined one.

Practice Exercises

  • Write a program with three async coroutines that each delay a different amount of time and return an Int. Sum all three with awaitAll() and print the total.
  • Take the “sequential await” mistake shown above with two 400ms delays and time how long the wrong version takes versus the fixed version (you can print System.currentTimeMillis() before and after) — confirm the fixed version is roughly half the time.
  • Write a function that uses launch to log “Task started” and “Task finished” around a delay, and explain in a comment why you couldn’t use the launched coroutine’s return value even if you wanted to.

Summary

  • launch starts a coroutine and returns a Job; use it for fire-and-forget work with no result.
  • async starts a coroutine and returns a Deferred<T>; call await() to suspend until its value is ready.
  • Suspending functions like delay and await free the underlying thread instead of blocking it, which is why coroutines scale far better than raw threads.
  • Start all the async calls you need before awaiting any of them, or you accidentally turn concurrent code back into sequential code.
  • Coroutines follow structured concurrency: a parent scope waits for all its children and propagates their failures, so avoid mixing in blocking calls like nested runBlocking.