Launch vs Async in Kotlin Coroutines
Launch vs Async in Kotlin Coroutines
Kotlin coroutines provide two primary coroutine builders for starting concurrent work: launch and async. While both create new coroutines within a structured concurrency scope, they serve fundamentally different purposes based on whether the caller needs a result from the concurrent operation. Choosing the wrong builder leads to subtle bugs: swallowed exceptions with async, unnecessary Deferred wrappers with launch, and broken structured concurrency when either is misused. By the end of this lesson, you will be able to:
- Explain the internal difference between
JobandDeferredand how the coroutine machinery dispatches each. - Describe how exception propagation differs between
launchandasyncand why this matters for crash handling. - Identify when
asyncwithoutawaitsilently swallows exceptions and how structured concurrency mitigates this. - Trace the execution flow of parallel decomposition with
asyncversus sequential fire and forget withlaunch. - Apply the correct builder in real Android code for use cases like parallel network calls, background writes, and UI updates.
Coroutine Builders and Their Return Types
Both launch and async are extension functions on CoroutineScope. They accept a CoroutineContext (defaulting to the scope's context), a CoroutineStart mode, and a suspending lambda that defines the coroutine's body. The key difference is in what they return.
launch returns a Job. A Job is a handle to the coroutine's lifecycle: it exposes whether the coroutine is active, completed, or cancelled, and provides join() to suspend until completion and cancel() to request cancellation. Importantly, a Job carries no result value. There is no way to extract a return value from a launch coroutine through its Job:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit // returns Unit
): Job
async returns a Deferred<T>, which is a subtype of Job with an added await() function. Deferred<T> represents a future result of type T. The suspending lambda passed to async has a return type of T instead of Unit, and calling await() on the Deferred suspends the caller until the result is available:
public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T // returns T
): Deferred<T>
Internally, both builders create a AbstractCoroutine subclass. launch creates a StandaloneCoroutine (or LazyStandaloneCoroutine for lazy start), while async creates a DeferredCoroutine. The DeferredCoroutine stores the result of the lambda in its internal state machine and delivers it when await() is called. The underlying coroutine scheduling, suspension, and resumption machinery is identical for both; the difference is purely in result handling and exception semantics.
Exception Propagation: The Key Behavioral Difference
Beyond return types, the most important difference between launch and async is how they handle exceptions. This distinction frequently causes production bugs when developers choose the wrong builder.
A launch coroutine propagates exceptions immediately to its parent scope. When an uncaught exception occurs inside a launch block, the StandaloneCoroutine calls handleCoroutineException(), which walks up the Job hierarchy to find a CoroutineExceptionHandler or, if none is installed, delivers the exception to the thread's uncaught exception handler. In an Android app without explicit handling, this crashes the application:
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
scope.launch {
throw RuntimeException("This crashes immediately")
// The exception propagates to the scope's exception handler
// or crashes the app if no handler is installed
}
An async coroutine does not propagate exceptions immediately. Instead, the exception is encapsulated inside the Deferred object. The exception is only thrown when await() is called. If no code ever calls await(), the exception is silently lost unless structured concurrency catches it. This behavior exists because async represents a computation whose result (or failure) will be consumed later:
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
val deferred = scope.async {
throw RuntimeException("This does NOT crash immediately")
// The exception is stored in the Deferred
}
// The app continues running normally here
try {
deferred.await() // Exception is thrown HERE
} catch (e: RuntimeException) {
Log.e("TAG", "Caught: ${e.message}")
}
This interview continues for subscribers
Subscribe to Dove Letter for full access to exclusive interviews about Android and Kotlin development.
Become a Sponsor