1. Two ways to start coroutines
When you first see coroutines, it is very tempting to ask: “OK, I get suspend and delay. But how do I make it so that several things happen at the same time?” This is where two heroes show up: launch and async. Both start a coroutine, but with different intent: one is “do an action”, the other is “compute a result”. And when the intent reads correctly in code, your brain suffers less.
It is important to nail down: both launch and async are “coroutine builders” from kotlinx.coroutines. The Kotlin docs describe this explicitly: coroutines are started with builders like .launch() and .async(), and these are extensions on CoroutineScope (meaning you can call them “inside a coroutine scope”, for example inside runBlocking).
2. launch: start a coroutine for actions
Imagine you hired a courier. You do not need them to return a “number” or a “string” — you need them to go and do: deliver a package, press a button, print a log, send an email. That is launch: “start a coroutine for a side effect”. Typically this is println, writing to a file, sending an event, updating state — i.e., an action, not computing a value.
Let’s start with a tiny example: we launch two coroutines that print strings with different delays.
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch {
delay(100)
println("L1 done") // L1 done
}
launch {
delay(50)
println("L2 done") // L2 done
}
println("Main continues") // Main continues
}
One important thing to be mentally prepared for: the line Main continues is usually printed first, and L2 done appears before L1 done, because its delay is smaller. In other words, the order is not “top to bottom”, but “whoever got there first” — and that is not a bug, that is the point.
Notice one more thing: launch does not return anything as a “computation result”. Technically it returns a Job, but today we are not turning that into a separate topic (managing coroutine lifecycle is a different conversation). The key point: launch is about an action, so it does not have a result contract like Int/String.
Concurrency vs. parallelism
Now for a short philosophical minute in 40–60 words, I promise, without boredom. When you start two coroutines via launch, you are not required to get “true parallelism” (like two people lifting two different sofas at the same time). You get concurrency: tasks can interleave, wait, and “yield” to others. That already provides a win when waiting and makes code nicer — and that is why we are here.
You can draw a simple time diagram (simplified):
sequenceDiagram
participant M as runBlocking/main
participant A as launch #1
participant B as launch #2
M->>A: start
M->>B: start
M->>M: println("Main continues")
A->>A: delay(100)
B->>B: delay(50)
B-->>M: println("L2 done")
A-->>M: println("L1 done")
3. async: a coroutine that promises a result
Now imagine not a courier, but a mathematician friend (everyone should have one… at least a virtual one). You ask: “Compute my tax total” or “Collect some stats”. You do not want just the “fact of an action”, you want a value. That is what async is for.
async { ... } starts a coroutine and returns an object of type Deferred<T>. Read it as: “a deferred result of type T”. As if you were given a slip of paper: “the result will be later, here is your ticket number”.
Why it matters: you can start several computations and then collect results at a convenient moment.
The Kotlin documentation describes that coroutines are started with builders like .async() the same way as .launch(), and they operate within a CoroutineScope.
4. Deferred<T> and await(): “promise” and “get”
At this point beginners often make the same mistake: they think Deferred<Int> is already an Int. No. It is a “waiting container”. To get the actual value, you must call await(). And await() is a suspend operation, because it can wait.
First I will show the “straight-on trap” — so you see it once and never step on it again.
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val d = async { 1 + 2 }
println(d) // DeferredCoroutine{Active}@...
println(d.await()) // 3
}
Here println(d) prints not 3, but an internal description of the Deferred object. And d.await() returns the result.
And let’s add a small analogy, without extra poetry. Deferred<T> is like a cafe receipt: you paid (started the computation), the receipt is in your hand, and the burger (T) will be brought a bit later. await() is like walking up to the counter and saying: “I am here for my order, here is the receipt.”
5. Pattern: several async, then await()
Now we are approaching the main practical “trick” of async: you start several tasks without awaiting immediately, and then you collect the results. This is important, because if you do await() right after each async, you may accidentally turn everything back into sequential execution.
Let’s compare two approaches. First the “unlucky” one (in terms of concurrency): start and immediately wait.
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val a = async { delay(100); 10 }.await()
val b = async { delay(100); 32 }.await()
println(a + b) // 42
}
This works, but the logic is: wait for a, then start b. Sometimes that is fine, but often you wanted something else.
Now the “more correct for parallel waiting” version: start first, then await.
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val a = async { delay(100); 10 }
val b = async { delay(100); 32 }
println(a.await() + b.await()) // 42
}
Here both tasks start immediately, and the total waiting time is usually closer to 100 ms, not 200 ms (we are simplifying, but the idea is exactly that).
This approach is very similar to examples from Kotlin materials where they show launching several concurrent tasks with coroutines: same idea — many independent jobs start, and then we just wait until they finish.
6. Practical example: “loading a profile” in the console
So that the examples are not “in a vacuum”, let’s assemble a small scenario: a console program that “loads a user profile”. For real, we are not going to the network or a DB yet (those are separate topics), so we will simulate long operations via delay.
Let’s imagine we need three things: a name, a balance, and the number of notifications. From a business perspective these are independent pieces, and we can do them concurrently.
Three suspend functions that “take a long time”
It is important to keep functions small and honest: if there is delay inside, the function should be suspend.
import kotlinx.coroutines.delay
suspend fun loadUserName(): String {
delay(120)
return "Alice"
}
suspend fun loadBalance(): Int {
delay(200)
return 150
}
suspend fun loadNotifications(): Int {
delay(80)
return 3
}
Even now you can notice something pleasant: the functions look like normal code, “top to bottom”. And that is one reason Kotlin likes coroutines so much: asynchronicity does not turn code into callback spaghetti.
Sequential version: simple and honest, but slower
First, let’s do it “the habitual way”: call the functions one after another. This is not an error — sometimes that is exactly what you need. But we want to see the difference.
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val name = loadUserName()
val balance = loadBalance()
val notifications = loadNotifications()
println("$name: $$balance, notifications=$notifications")
// Alice: $150, notifications=3
}
Meaning: we wait 120 ms, then another 200, then another 80. The waiting “adds up”.
Concurrent version: start everything at once and wait for results
Now the same logic, but with async. We start three computations, get three “promises”, and then extract results via await().
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val nameD = async { loadUserName() }
val balanceD = async { loadBalance() }
val notifD = async { loadNotifications() }
println("${nameD.await()}: $${balanceD.await()}, notifications=${notifD.await()}")
// Alice: $150, notifications=3
}
The result is the same, but the “waiting time” should be closer to the slowest piece (in our case loadBalance() at 200 ms), not the sum of all delays. And this already resembles real life: often we are waiting on network/disk, and concurrency makes the app feel “more responsive”, even though it does not magically speed up the CPU.
7. Where to use launch and where to use async
It is very easy to slide into “I will wrap everything in async, because I can”. Usually that is a bad idea: code becomes less expressive. We want the intent to be visible from the code: “I am starting an action” or “I am starting a result computation”.
Below is a small table that helps you choose the tool without guessing:
| Situation in code | What is more appropriate | Why |
|---|---|---|
| Need to print a log / show a message / write something | |
No result needed; the fact of the action matters |
| Need to get a value (number/string/list) | |
There is a result; it must be returned and used |
| Want to start 2–5 independent computations and then collect results | |
Expresses “parallel waiting” well |
| Want to do background “parallel” printing/indicator (simplified) | |
That is a side effect (output), not a computation |
Note: for now we are not discussing the subtleties of lifecycle management (cancellation, Job, hierarchies). Today it is important to learn to choose the right launch form for the task without overcomplicating things.
Can you mix launch and async
In real programs you often do both: concurrently “print/log something” and concurrently “compute/load something”. The only important thing is not to turn the program into a fireworks show where everything flashes and nobody understands who is responsible for what.
Let’s do a small example: we concurrently start data loading (async) and print “start/finish” as separate actions (launch).
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
launch { println("Loading profile...") } // Loading profile...
val nameD = async { delay(100); "Alice" }
val notifD = async { delay(80); 3 }
println("Hi, ${nameD.await()}! You have ${notifD.await()} notifications.")
// Hi, Alice! You have 3 notifications.
}
Yes, here launch looks “unnecessary”, because you could just do println directly. But as a learning example it shows the difference in intent: launch is “an action on its own”, async is “a result on its own”.
8. Minimal notes on exceptions in coroutines
When you write concurrent code, exceptions do not go anywhere. They just become more “surprising” if you do not understand where exactly you “receive” them.
With async, an important practical detail is this: if an error happens inside async { ... }, it often “surfaces” at the moment of await() (because that is where you try to get the result).
I will show a short example: division by zero. Do not try this at home… actually, do try it — and watch the behavior.
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
fun main() = runBlocking {
val d = async { 10 / 0 }
println("Before await") // Before await
println(d.await()) // ArithmeticException here
}
We see that the program manages to print Before await, and then crashes at await(). That is logical: the error happened “in the result computation”, and await() is the point where you request the result outside.
With launch, the story is also not trivial, but today we are not diving into exception handling strategies for coroutine groups. Just keep an honest rule in mind: concurrency does not remove the need to think about errors; it only changes where you notice them.
9. Common mistakes when working with launch and async/await
Mistake #1: using launch when you need a result.
A beginner often starts launch { val x = ... }, and then tries to “somehow return x outside”. This looks like trying to carry water in a sieve: launch is designed to “do an action”, and that is exactly why it does not give you a proper result contract of type T. If you need a value, use async and take the result via await().
Mistake #2: forgetting that Deferred<T> is not T.
Deferred is not a number and not a string — it is a “promise”. When you print a Deferred, you will see internal info, not the result. If you then pass a Deferred where an Int is expected, the compiler will (rightfully) complain. The fix is simple: discipline around await() exactly where you actually need the result.
Mistake #3: calling await() immediately after each async, and then being surprised that “there is no speed-up”.
If you write async {...}.await() twice in a row, you are effectively doing “start → wait”, “start → wait”. Concurrency is lost, and the total waiting time becomes similar to the sum of delays again. The correct pattern: start several async first, then await them.
Mistake #4: wrapping everything in coroutines for no reason.
Sometimes the fastest way to make a program slower and more confusing is to add concurrency “just in case”. If an operation is instantaneous (for example, val x = 1 + 2), async only complicates the code. Coroutines are especially useful where there is waiting or independent chunks of work.
Mistake #5: confusing a “coroutine” with a “thread” and expecting strict parallelism.
A coroutine is lightweight and can suspend; a thread is a heavy OS resource. Coroutines can run concurrently, and that is already useful. But nobody promised “mandatory parallelism” in the sense of “two coroutines = two CPU cores”.
GO TO FULL VERSION