SHAKIL.DEV

SHAKIL.DEV

Kotlin Coroutines: From Basics to Advanced Patterns
← BACK TO TRANSMISSIONS
January 19, 2026

Kotlin Coroutines: From Basics to Advanced Patterns

Kotlin Coroutines from basics to advanced: suspend functions, launch vs async, dispatchers, structured concurrency, cancellation, error handling and Flow.

Kotlin Coroutines are the standard way to do asynchronous work on Android — network calls, database queries, and anything that would otherwise freeze the UI. This guide takes you from the basics (suspend, launch, async) to advanced patterns used in production apps: dispatchers, structured concurrency, cancellation, error handling, and Kotlin Flow.

Still learning Kotlin? Start with Kotlin Basics for Android Beginners.

What Are Coroutines?

A coroutine is a piece of work that can pause and resume without blocking a thread. Coroutines are often called "lightweight threads": you can run thousands of them on a handful of threads.

The big win is readability. Asynchronous code looks like normal, top-to-bottom code — no nested callbacks:

viewModelScope.launch {
    val user = userRepository.getUser()   // suspends, doesn't block the main thread
    _uiState.value = UiState.Success(user)
}

Suspend Functions

A suspend function can pause its coroutine while it waits (for the network, the disk, a delay) and resume later on the right thread.

suspend fun fetchUser(id: String): User {
    delay(1000)            // simulated network wait, non-blocking
    return User(id, "Shakil")
}

Suspend functions can only be called from another suspend function or from a coroutine builder like launch.

launch vs async

  • launch starts a coroutine that does work and doesn't return a result ("fire and forget").
  • async starts a coroutine that returns a result through a Deferred; you get it with await().

Use async to run independent tasks in parallel:

suspend fun loadUserData(userId: String): UserData = coroutineScope {
    val profile = async { loadProfile(userId) }
    val posts = async { loadPosts(userId) }

    UserData(
        profile = profile.await(),
        posts = posts.await()
    )
}

Both requests run at the same time, so the total time is the slower of the two, not the sum.

Dispatchers: Choosing the Right Thread

A dispatcher decides which thread a coroutine runs on:

DispatcherUse for
Dispatchers.MainUI updates (the default in viewModelScope and lifecycleScope)
Dispatchers.IONetwork, disk, database
Dispatchers.DefaultCPU-heavy work: sorting large lists, parsing, image processing

Switch with withContext, ideally inside the repository so callers never have to think about it (this is called being main-safe):

class ReportRepository(
    private val api: ReportApi,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    suspend fun getReport(): Report = withContext(ioDispatcher) {
        api.fetchReport()
    }
}

Injecting the dispatcher makes the class easy to test with a test dispatcher.

Scopes and Structured Concurrency

Every coroutine runs in a scope. When the scope is cancelled, all its coroutines are cancelled too. This is structured concurrency, and it prevents leaks.

On Android you'll mostly use:

  • viewModelScope – cancelled when the ViewModel is cleared.
  • lifecycleScope – cancelled when the Activity/Fragment is destroyed.
  • coroutineScope { } – creates a child scope inside a suspend function; it waits for all children and fails if any child fails.
  • supervisorScope { } – like coroutineScope, but one child failing doesn't cancel the others.

Avoid GlobalScope. Its coroutines are not tied to any lifecycle and easily leak.

To collect flows safely in an Activity or Fragment, use repeatOnLifecycle:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { render(it) }
    }
}

In Compose, collectAsStateWithLifecycle() does the same thing for you — see Jetpack Compose: State Management Best Practices.

Cancellation

Coroutine cancellation is cooperative: a coroutine must check for it. All suspending functions from kotlinx.coroutines (delay, withContext, etc.) check automatically. For long CPU loops, check yourself:

suspend fun processItems(items: List<Item>) = withContext(Dispatchers.Default) {
    for (item in items) {
        ensureActive()      // throws if the coroutine was cancelled
        process(item)
    }
}

Error Handling

Use try/catch around suspending calls, just like normal code:

viewModelScope.launch {
    try {
        val result = repository.loadData()
        _uiState.value = UiState.Success(result)
    } catch (e: CancellationException) {
        throw e                                   // never swallow cancellation
    } catch (e: IOException) {
        _uiState.value = UiState.Error("Check your internet connection")
    }
}

Important rules:

  • Always rethrow CancellationException. Catching it (for example with a generic catch (e: Exception) or runCatching) can keep a cancelled coroutine running.
  • Exceptions in launch propagate to the parent and cancel siblings. Exceptions in async are thrown when you call await().
  • Use supervisorScope when independent tasks should fail independently.
  • A CoroutineExceptionHandler on a root scope is a last-resort logger, not a substitute for handling errors.

Kotlin Flow for Streams of Data

A suspend function returns one value. A Flow emits many values over time — perfect for database updates, search-as-you-type, or live data.

class MessageRepository(private val dao: MessageDao) {
    fun observeMessages(): Flow<List<Message>> = dao.observeAll()   // Room emits on every change
}

Transform flows with operators:

val unreadCount: Flow<Int> = repository.observeMessages()
    .map { messages -> messages.count { !it.isRead } }
    .distinctUntilChanged()

Flows are cold: nothing runs until someone collects. To share one flow with the UI, turn it into a StateFlow in the ViewModel with stateIn:

val uiState: StateFlow<UiState> = repository.observeMessages()
    .map { UiState.Success(it) as UiState }
    .catch { emit(UiState.Error(it.message ?: "Error")) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState.Loading
    )

WhileSubscribed(5_000) keeps the upstream alive for 5 seconds after the UI stops collecting, so a screen rotation doesn't restart the work.

Testing Coroutines

Use runTest from kotlinx-coroutines-test. It skips delays automatically, so tests run instantly:

@Test
fun `loads user`() = runTest {
    val repo = UserRepository(FakeApi(), StandardTestDispatcher(testScheduler))
    assertEquals("Shakil", repo.getUser("1").name)
}

Best Practices

  1. Make suspend functions main-safe by switching dispatchers inside them.
  2. Inject dispatchers instead of hardcoding Dispatchers.IO.
  3. Use viewModelScope / lifecycleScope; avoid GlobalScope.
  4. Never swallow CancellationException.
  5. Prefer Flow and StateFlow over LiveData in new code.
  6. Collect flows with repeatOnLifecycle or collectAsStateWithLifecycle.
  7. Expose immutable StateFlow / Flow from ViewModels; keep the mutable version private.

Frequently Asked Questions

Are coroutines threads? No. Coroutines run on threads, but many coroutines can share one thread because they suspend instead of blocking.

What's the difference between Flow, StateFlow and SharedFlow? Flow is cold and emits values to each collector. StateFlow is hot, always has a current value, and is ideal for UI state. SharedFlow is hot and can broadcast values to many collectors without holding a "current" state.

Should I still use LiveData? It still works, but for new code StateFlow is the recommended choice: it's Kotlin-first, works outside Android, and supports all Flow operators.

Conclusion

Coroutines make asynchronous Android code readable, safe and testable. Master suspend, scopes, dispatchers, cancellation and Flow, and you'll write apps that stay fast and responsive. To see how coroutines fit into a full app structure, read Building Clean Architecture in Android.