launch/join vs async/await in Kotlin: what to choose in 2025? Full breakdown with examples
\n\nHello, colleague! If you've ever written Kotlin coroutines, you've probably wondered: "What's the actual difference between launch and async? And why is this mysterious join needed?". This question is a real hit on Stack Overflow, having gathered thousands of views. And for good reason: at first glance, both tools do the same thing — launch a background task. But the devil, as always, is in the details.
In 2025, when Kotlin coroutines have become mainstream in Android development and backend, understanding these differences is not just a whim, but a necessity for writing clean, safe, and performant code. In this article, we won't just list dry facts. We'll break down real scenarios, look at the code, and derive best practices that you can confidently apply in your projects right now.
\n\nLet's go!
\n\nlaunch: "Fire and forget"
\n\nThe launch function is perhaps the simplest way to start a coroutine. It creates a new coroutine that does not return a result. Its job is to perform some work: update the UI, send a log, write data to a file. The return type is Job. It's like giving a task to a courier: "Deliver the package and come back, no report needed".
The join method allows you to wait for this coroutine to complete. It suspends the execution of the current coroutine until the job that called join finishes.
Let's look at an example. Suppose we need to load two images in parallel and then combine them. Using launch and join, it would look like this:
import kotlinx.coroutines.*\n\nsuspend fun loadImage(url: String): Image {\n // simulating loading\n delay(1000)\n return Image(url)\n}\n\ndata class Image(val url: String)\n\nsuspend fun loadTwoImagesWithLaunch() = coroutineScope {\n var image1: Image? = null\n var image2: Image? = null\n\n val job1 = launch {\n image1 = loadImage("https://example.com/img1.png")\n }\n\n val job2 = launch {\n image2 = loadImage("https://example.com/img2.png")\n }\n\n // Wait for both tasks\n job1.join()\n job2.join()\n\n // Now image1 and image2 are guaranteed to be loaded\n println("Images loaded: $image1 and $image2")\n}\n\nWhat's important here? We use mutable variables (var) to store the result. This is not very elegant and can lead to threading issues, although coroutines work within a single thread. But the main drawback is that we cannot use launch to return a value directly.
async: "Launch and get the result"
\n\nNow async is the "business class". It creates a coroutine that returns a result as a Deferred<T> object. It's like ordering a taxi: you wait, and you get back not just the fact of the trip, but a concrete result — you've arrived. To get this result, await() is used.
The same example with loading images, but using async/await, becomes more elegant:
import kotlinx.coroutines.*\n\nsuspend fun loadImage(url: String): Image {\n delay(1000)\n return Image(url)\n}\n\ndata class Image(val url: String)\n\nsuspend fun loadTwoImagesWithAsync() = coroutineScope {\n val deferred1 = async { loadImage("https://example.com/img1.png") }\n val deferred2 = async { loadImage("https://example.com/img2.png") }\n\n // await() suspends the coroutine until it gets the result\n val image1 = deferred1.await()\n val image2 = deferred2.await()\n\n println("Images loaded: $image1 and $image2")\n}\n\nCan you feel the difference? No var, no manual management. Everything is clean,