Most coroutine bugs I see in code review are not syntax mistakes. They are routing mistakes: an exception that went somewhere nobody was listening, a failure that cancelled work it had no business cancelling, or a blocking call that quietly froze the UI. All of them trace back to picking the wrong builder for the job.

Kotlin gives you six everyday tools for starting concurrent work: launch, async, runBlocking, coroutineScope, supervisorScope, and withContext. Strictly speaking, only the first three are coroutine builders; the last three are scoping and context functions. In practice you choose among all six at the same decision point, so this post treats them as one family.

Every choice comes down to three questions:

  1. Do I need a result? (value back, or just a side effect)
  2. How should failure travel? (cancel everybody, or contain it)
  3. Which thread should this run on?

Keep those three in your head and the rest of this post is detail.

coroutine-builders-cheat-sheet

The running example: TrailLog

All examples below come from a fictional hiking app called TrailLog. It loads trail details, forecasts and elevation profiles, records GPX tracks, uploads photos, and shows a home feed. Here is the minimal surface the snippets rely on:

@JvmInline value class TrailId(val value: String) @JvmInline value class UserId(val value: String) interface TrailApi { suspend fun details(id: TrailId): TrailDetails suspend fun forecast(id: TrailId): Forecast suspend fun elevationProfile(id: TrailId): ElevationProfile suspend fun recentHikes(user: UserId): List<Hike> suspend fun badges(user: UserId): List<Badge> suspend fun regionalWeather(): RegionalWeather suspend fun uploadTrack(operationId: String, gpx: String): String suspend fun uploadPhoto(operationId: String, photo: Photo) suspend fun commitHike(operationId: String, trackId: String): PublishedHike suspend fun discardHike(operationId: String) suspend fun syncBadges() } data class TrailScreenData( val details: TrailDetails, val forecast: Forecast, val elevation: ElevationProfile, ) 

1. launch: fire and forget

launch starts a coroutine and hands you back a Job. There is no result to collect. You get a handle for cancellation and nothing else.

class TrailViewModel( private val analytics: Analytics, ) : ViewModel() { fun onTrailOpened(id: TrailId) { viewModelScope.launch { analytics.track(event = "trail_opened", property = id.value) } } } 

Reach for it when the caller does not care when the work finishes: analytics, cache warming, persisting a draft.

Failure mode: the try/catch that never fires

This is the classic one. The launch call returns immediately, so the try block has already exited by the time the coroutine throws.

fun onSyncClicked() { try { viewModelScope.launch { repository.sync() } // throws IOException later } catch (e: IOException) { showError() // never reached } } 

The exception is not delivered to the caller. It travels up the job hierarchy. viewModelScope uses a SupervisorJob, so siblings survive, but with no handler installed the exception reaches the thread's uncaught-exception handler and your app crashes.

Put the try/catch inside the coroutine, and be careful with cancellation:

fun onSyncClicked() { viewModelScope.launch { try { repository.sync() } catch (e: CancellationException) { throw e // never swallow cancellation } catch (e: IOException) { _events.emit(UiEvent.SyncFailed) } } } 

That rethrow line matters more than it looks. CancellationException is how structured concurrency tells a coroutine to stop. A blanket catch (e: Exception) swallows it, and now your coroutine keeps running after its screen is gone. I keep a tiny helper to make the right thing the easy thing:

suspend inline fun <T> suspendRunCatching(block: () -> T): Result<T> = try { Result.success(block()) } catch (e: CancellationException) { throw e } catch (e: Throwable) { Result.failure(e) } 

Unlike the standard runCatching, this one never turns a cancellation into a "failed" result. Because the function is inline, the lambda you pass is inlined into the caller, so it can call other suspending functions (like repository.sync()) without being declared suspend itself.

2. async: parallel work with a result

async returns a Deferred<T>. Call .await() when you need the value.

suspend fun loadTrailScreen(id: TrailId): TrailScreenData = coroutineScope { val details = async { api.details(id) } val forecast = async { api.forecast(id) } val elevation = async { api.elevationProfile(id) } TrailScreenData( details = details.await(), forecast = forecast.await(), elevation = elevation.await(), ) } 

Three network calls run concurrently, so total latency is roughly the slowest call, not the sum. (Note the coroutineScope wrapper; async needs a scope to live in, and I will come back to why this particular scope is the right one.)

Failure mode: parallelism that isn’t

This looks parallel. It is sequential:

val details = async { api.details(id) }.await() // starts, then immediately waits val forecast = async { api.forecast(id) }.await() // only starts after the first finishes 

Calling .await() right on the async call suspends before the next one is even created. Start everything first, await afterwards. When you have a list, awaitAll() keeps it tidy:

suspend fun loadAllForecasts(ids: List<TrailId>): List<Forecast> = coroutineScope { ids.map { id -> async { api.forecast(id) } }.awaitAll() } 

Failure mode: the exception you caught but didn’t stop

viewModelScope.launch { val forecast = async { api.forecast(id) } try { show(forecast.await()) } catch (e: IOException) { showOfflineBanner() // runs... } } // ...and the app still crashes 

Here async is a child of a plain launch. When it fails, it does two things: it stores the exception for await(), and it cancels its parent. Your catch handles the first; the second has already marked the parent as failed, and that failure reaches the crash handler. The fix is to give the async its own boundary so the failure stops there:

viewModelScope.launch { val forecast = try { coroutineScope { async { api.forecast(id) }.await() } } catch (e: CancellationException) { throw e } catch (e: IOException) { null } show(forecast) } 

Failure mode: the leaked Deferred

GlobalScope.async { ... } has no parent, so nothing cancels it when the user leaves the screen, and nobody observes its exception unless somebody awaits it. The library flags this on purpose: GlobalScope is annotated @DelicateCoroutinesApi, so using it produces a compiler warning unless you opt in explicitly with @OptIn(DelicateCoroutinesApi::class). Treat that warning as a design signal rather than something to suppress. You almost certainly want an application-level scope you own and inject instead:

class AppScope @Inject constructor() { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) } 

Because you own it, you decide its lifetime, you can swap its dispatcher in tests, and you can attach a CoroutineExceptionHandler to it once.

3. coroutineScope: structured grouping, all or nothing

coroutineScope suspends until all of its children finish. If any child fails, the siblings are cancelled and the exception is rethrown to the caller.

data class HikeDraft( val operationId: String, // generated once when the draft is created, persisted with it val gpx: String, val photos: List<Photo>, // each Photo has a stable id ) suspend fun publishHike(draft: HikeDraft): PublishedHike { val trackId = coroutineScope { val track = async { api.uploadTrack(draft.operationId, draft.gpx) } draft.photos.forEach { photo -> launch { api.uploadPhoto(draft.operationId, photo) } } track.await() } // The scope has returned, so the track and every photo are staged. return api.commitHike(draft.operationId, trackId) } 

Two properties make this the right tool when you need every piece to succeed:

  • The coroutineScope block returns only after the track and every photo upload finish, even though the last expression is available earlier. No orphaned coroutines escape. That is a statement about coroutines, not about the server; see the failure mode below.
  • If one photo upload throws, the remaining uploads and the track upload are cancelled, and the caller gets the exception.

That second property is what lets the caller use ordinary error handling:

try { val published = publishHike(draft) showSuccess(published) } catch (e: CancellationException) { throw e } catch (e: IOException) { showRetryDialog() } 

Because coroutineScope rethrows, a plain try/catch around it works. That is exactly what did not work around launch.

Failure mode: mistaking cancellation for rollback

coroutineScope gives you all-or-nothing completion, not all-or-nothing state. If one photo upload throws, the scope cancels its siblings and rethrows, but a photo that finished a moment earlier is already stored on the server. Cancellation stops work in flight; it cannot reach across the network and undo committed work.

The coroutine boundary and the server-side commit boundary solve different problems. To make publishHike safe to retry, three things have to be true:

  • A stable operation ID. Generate it when the draft is created and persist it, so a retry after process death reuses it. Generating it inside publishHike defeats the purpose.
  • Idempotent steps. The server keys each upload by (operationId, photoId), so re-uploading a photo that already landed is a no-op, not a duplicate.
  • Staging plus a separate commit. Uploads stay invisible until commitHike runs. If anything fails before that, nothing is published. The server expires staged data after a TTL, and the client can call discardHike(operationId) on explicit user cancel, so nothing is permanently orphaned.

Note where commitHike sits: after the coroutineScope block, not inside it. Children started with launch may still be running when the last expression of the block is evaluated, so committing from inside the scope could publish before every upload has finished.

Then test the retry explicitly:

@Test fun `retry after partial failure neither duplicates nor orphans`() = runTest { val api = FakeTrailApi(failPhotoUpload = setOf("photo-2")) // photo-1 succeeds, photo-2 fails val repo = TrailRepository(api) val draft = draftWith(photos = listOf(photo("photo-1"), photo("photo-2"))) assertFailsWith<IOException> { repo.publishHike(draft) } assertEquals(setOf("photo-1"), api.stagedPhotos(draft.operationId)) assertFalse(api.isPublished(draft.operationId)) api.failPhotoUpload = emptySet() repo.publishHike(draft) // same draft, same operationId assertEquals(setOf("photo-1", "photo-2"), api.stagedPhotos(draft.operationId)) assertEquals(1, api.storedCopies("photo-1")) // no duplicate of the first photo assertTrue(api.isPublished(draft.operationId)) } @Test fun `abandoned publish is reclaimed`() = runTest { val api = FakeTrailApi(failPhotoUpload = setOf("photo-2")) val repo = TrailRepository(api) val draft = draftWith(photos = listOf(photo("photo-1"), photo("photo-2"))) assertFailsWith<IOException> { repo.publishHike(draft) } api.advanceTimeBy(STAGING_TTL) // user never retries assertTrue(api.orphanedUploads().isEmpty()) } 

One caveat: this assumes you control the backend or your API supports idempotency keys. If it doesn’t, the best the client can do is record which uploads succeeded locally (in Room, say) and skip them on retry. That narrows the duplicate window but doesn’t close it.

Failure mode: expecting partial results

If you wrap three independent widgets in a coroutineScope, one slow-failing widget cancels the other two mid-flight and your user sees nothing. That is the right behavior for a transaction and the wrong behavior for a dashboard. Which brings us to the next builder.

4. supervisorScope: independent children

supervisorScope waits for all children like coroutineScope does, but a failing child does not cancel its siblings and does not fail the scope.

scope-vs-supervisor-failure

suspend fun loadHomeFeed(user: UserId): HomeFeed = supervisorScope { val recent = async { api.recentHikes(user) } val badges = async { api.badges(user) } val weather = async { api.regionalWeather() } HomeFeed( recent = recent.awaitOrNull(), badges = badges.awaitOrNull(), weather = weather.awaitOrNull(), ) } private suspend fun <T> Deferred<T>.awaitOrNull(): T? = try { await() } catch (e: CancellationException) { throw e } catch (e: Exception) { null } 

If the badges endpoint is down, the feed renders with recent hikes and weather and a null where badges would go. The UI can show a "couldn't load badges" placeholder for that one card.

Failure mode: launch inside supervisorScope with no handler

Supervision stops a failure from cancelling siblings. It does not make the failure disappear. An exception from a launch child still has to go somewhere:

supervisorScope { launch { api.syncBadges() } // throws -> uncaught -> crash } 

Install a handler on the child, or catch inside it:

val handler = CoroutineExceptionHandler { _, throwable -> logger.warn("badge sync failed", throwable) } supervisorScope { launch(handler) { api.syncBadges() } } 

Compare this with async in the same scope: the exception stays inside the Deferred until somebody calls await(). That asymmetry is the heart of the next diagram.

Failure mode: launch(SupervisorJob())

You will see this “fix” suggested for isolation:

scope.launch(SupervisorJob()) { ... } // looks right, behaves wrong 

Passing a fresh SupervisorJob() as the launch context replaces the parent. The new coroutine is no longer a child of your scope, so cancelling the scope will not cancel it, and the scope will not wait for it. You have traded failure isolation for a structured-concurrency leak. Use supervisorScope { }, or build the scope itself with a SupervisorJob once.

Failure mode: assuming deep isolation

supervisorScope isolates its direct children from one another. If one of those children spawns its own regular children, a failure among those grandchildren cancels that child as usual. Supervision applies one level down, not recursively.

Where does an exception actually go?

The combination of builder and enclosing scope determines the answer. This map is worth printing and sticking next to your monitor:

exception-routing-map

The practical reading:

  • Inside coroutineScope, everything funnels to one place: the scope rethrows to your try/catch.
  • Inside supervisorScope, launch needs a handler and async needs an await() that you guard.
  • In viewModelScope or lifecycleScope, a failed launch keeps its siblings alive, yet it is still a crash unless you handle it. An async that is a direct child of those scopes is catchable at await().

5. withContext: the dispatcher switch

withContext runs a block under a different context, usually a different dispatcher, then resumes the caller with the result. It is sequential: the caller suspends until the block completes.

class TrackImporter( private val dao: TrackDao, private val parser: GpxParser, private val io: CoroutineDispatcher = Dispatchers.IO, private val cpu: CoroutineDispatcher = Dispatchers.Default, ) { suspend fun importGpx(file: File): TrackSummary { val raw = withContext(io) { file.readText() } val track = withContext(cpu) { parser.parse(raw) } withContext(io) { dao.insert(track.toEntity()) } return track.summary() } } 

Two habits are visible here. The function is main-safe: a caller on Dispatchers.Main can call importGpx without thinking about threads, because the function moves itself. And dispatchers are injected, which is what lets tests swap in a test dispatcher.Source:https://dev.to/sivavishnu_r_1ee8971ed42d/six-builders-one-mental-model-choosing-the-right-kotlin-coroutine-builder-in-a-production-2cig