What Are Builders?
Coroutine builders are creation mechanisms that define how a coroutine starts, what it returns, how it behaves, and how it participates in structured concurrency.
Using the correct builder is essential for writing predictable, leak-free, and efficient asynchronous Kotlin code.
launch and Fire-and-Forget Tasks
launch starts a coroutine whose purpose is performing work, not returning values.
It returns a Job, which represents the lifecycle of the coroutine.
Job States
A coroutine’s Job transitions through clear states:
| State | Meaning |
|---|---|
| New | Job created but not started (rare with launch) |
| Active | Coroutine is running or scheduled to run |
| Completing | Children are finishing before completion |
| Cancelling | Cancellation requested, coroutine is cleaning up |
| Cancelled | Coroutine finished with cancellation |
| Completed | Coroutine finished normally |
Internally, these states come from the JobSupport implementation in kotlinx.coroutines.
Cancelling a Job
Cancellation is cooperative — a coroutine must hit a suspension point to actually stop.
val job = launch {
repeat(10) {
println("Working $it")
delay(500)
}
}
delay(1200)
job.cancel() // Request cancellation
job.join() // Wait for completion
Cancellation notes:
$job.cancel()sends a cancellation signal. Execution stops at the next suspension point.- Use
job.join()aftercancel()if you want to wait for cleanup. - Cancellation throws
CancellationExceptioninternally (usually invisible unless caught).
Best Practices & Warnings
- Always cancel jobs tied to UI lifecycles (e.g., Activity, ViewModel).
- Prefer using scoped builders (
viewModelScope.launch,lifecycleScope.launch) to avoid leaks. - Avoid
GlobalScope.launchunless building an app-wide worker. - Avoid catching
CancellationExceptionunless you rethrow, or you will break cancellation propagation.
async for Concurrent Deferred Tasks
async is used for concurrent operations that return results.
It returns a Deferred<T>, which is both:
- A future-like result holder (
await()) - A child coroutine
Job
Why You Need to Call await()
async starts execution immediately (unless configured otherwise).
But exceptions thrown inside an async block are:
- Stored until you call
await() - Re-thrown at the awaiting call site
Example:
val deferred = async {
throw RuntimeException("Failed!")
}
deferred.await() // Exception is thrown HERE
If you forget to call await():
- Exceptions are lost until the coroutine is garbage collected
- The parent scope may not see the failure
- This leads to silent failures → extremely hard to debug
Using CoroutineStart.LAZY (Deep Explanation)
val deferred = async(start = CoroutineStart.LAZY) {
expensiveOperation()
}
Meaning:
- The coroutine is created but not started.
- It starts only when:
- You call
await() - OR you call
start()manually
- You call
Use cases:
- Precise control over execution timing
- Conditional parallelism
- Avoid unnecessary work
Example:
val user = async(start = CoroutineStart.LAZY) { api.loadUser() }
val settings = async(start = CoroutineStart.LAZY) { api.loadSettings() }
// Both start here
val result = UserData(user.await(), settings.await())
Cancelling async
Deferred inherits Job, so cancellation works the same:
val deferred = async {
delay(5000)
"Done"
}
delay(1000)
deferred.cancel()
Cancellation notes:
- A cancelled
Deferredthrows aCancellationExceptionwhen awaited. - If you know a
Deferredmay be cancelled early, wrap cleanup intry/finally.
Best Practices for async
- Always call
await()unless intentionally discarding the result. - Use
asynconly for parallelism, not for fire-and-forget. - Perform
asyncinside a structured scope (coroutineScope). - Prefer sequential code unless parallelism brings real benefit.
runBlocking
runBlocking bridges blocking and suspending worlds.
It blocks the current thread until all coroutines inside it finish.
Use cases:
- Unit tests
main()entry point- Integrating with frameworks requiring blocking code
Avoid using in Android or any UI environment.
withContext — Switch Contexts Predictably and Safely
withContext is a suspend function that:
- Does not create a new coroutine
- Suspends the current coroutine**
- Switches to a different dispatcher
- Returns to the original context after completion
Benefits (Strengths)
Predictable thread switching
Example: IO → Main is straightforward:
val data = withContext(Dispatchers.IO) { loadData() }
withContext(Dispatchers.Main) { render(data) }
No new coroutine; clean, sequential code.
Structured concurrency
It honors scope cancellation and exception propagation.
Ideal for CPU or IO boundaries
You can move expensive work off the main thread.
Thread-safety via confinement
Running code on a single-thread dispatcher (e.g., actor model) is easy.
Drawbacks (Weaknesses)
Sequential, not concurrent
withContext does not run work in parallel.
Bad example:
withContext(IO) { loadUser() }
withContext(IO) { loadSettings() }
→ runs sequentially
→ use async for parallelism.
Too many context switches = overhead
While switching is lightweight, excessive switching inside tight loops hurts performance.
Bad:
items.forEach {
withContext(Dispatchers.IO) { save(it) }
}
Better:
withContext(Dispatchers.IO) {
items.forEach { save(it) }
}
Blocking inside withContext is dangerous
If you block:
withContext(IO) {
Thread.sleep(1000) // BAD
}
You exhaust the IO pool threads → starvation → slowdowns.
Use suspending I/O APIs instead.
coroutineScope — Builder That Enforces Structured Concurrency
coroutineScope creates a new scope where:
- All child coroutines must complete before the block returns
- Failures cancel siblings
- No threads are blocked
It is often used inside suspend functions to safely launch multiple concurrent subtasks.
Example
suspend fun loadEverything() = coroutineScope {
val user = async { api.loadUser() }
val messages = async { api.loadMessages() }
Combined(user.await(), messages.await())
}
Difference from supervisorScope
coroutineScope: child failure cancels all siblingssupervisorScope: children fail independently
When to use
- Inside suspend functions that spawn child coroutines
- When you want to enforce proper cleanup and avoid leaks
Conclusion
Each coroutine builder has precise semantics:
| Builder | Suitable For | Returns | Notes |
|---|---|---|---|
launch | Fire-and-forget tasks | Job | Cancel with job.cancel(), stateful lifecycle |
async | Concurrent tasks with result | Deferred<T> | Must await(), supports LAZY start |
runBlocking | Tests, CLI main | T | Blocks thread → avoid in UI |
withContext | Thread switching | T | Sequential, not concurrent; predictable |
coroutineScope | Child task grouping | T | Enforces structured concurrency |
Understanding when and why to choose each builder prevents:
- Silent failures
- Lost exceptions
- Coroutine leaks
- Unnecessary thread switching
- Misused concurrency patterns
