Coroutine Builders: launch, async, runBlocking

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:

StateMeaning
NewJob created but not started (rare with launch)
ActiveCoroutine is running or scheduled to run
CompletingChildren are finishing before completion
CancellingCancellation requested, coroutine is cleaning up
CancelledCoroutine finished with cancellation
CompletedCoroutine 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() after cancel() if you want to wait for cleanup.
  • Cancellation throws CancellationException internally (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.launch unless building an app-wide worker.
  • Avoid catching CancellationException unless 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

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 Deferred throws a CancellationException when awaited.
  • If you know a Deferred may be cancelled early, wrap cleanup in try/finally.

Best Practices for async

  • Always call await() unless intentionally discarding the result.
  • Use async only for parallelism, not for fire-and-forget.
  • Perform async inside 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 siblings
supervisorScope: 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:

BuilderSuitable ForReturnsNotes
launchFire-and-forget tasksJobCancel with job.cancel(), stateful lifecycle
asyncConcurrent tasks with resultDeferred<T>Must await(), supports LAZY start
runBlockingTests, CLI mainTBlocks thread → avoid in UI
withContextThread switchingTSequential, not concurrent; predictable
coroutineScopeChild task groupingTEnforces structured concurrency

Understanding when and why to choose each builder prevents:

  • Silent failures
  • Lost exceptions
  • Coroutine leaks
  • Unnecessary thread switching
  • Misused concurrency patterns

Leave a Reply

Your email address will not be published. Required fields are marked *