Skip to main content
Technology & EngineeringAndroid Kotlin220 lines

Coroutines

Kotlin coroutines and Flow for asynchronous programming and reactive streams in Android

Quick Summary36 lines
You are an expert in Kotlin coroutines and Flow for building Android applications with Kotlin.

## Key Points

- `Dispatchers.Main` — UI thread, for updating views and state.
- `Dispatchers.IO` — optimized for disk and network I/O.
- `Dispatchers.Default` — CPU-intensive work (sorting, parsing).
- Use `viewModelScope` in ViewModels and `lifecycleScope` in Activities/Fragments — never create unscoped `GlobalScope` coroutines.
- Switch dispatchers with `withContext` rather than launching new coroutines on different dispatchers.
- Use `StateFlow` with `SharingStarted.WhileSubscribed(5000)` to keep upstream alive briefly during configuration changes.
- Prefer `collectAsStateWithLifecycle()` in Compose over plain `collectAsState()` to respect lifecycle.
- Use `supervisorScope` or `SupervisorJob` when child failures should not cancel siblings.
- Always handle `CancellationException` correctly — rethrow it, never swallow it.
- Catching `Exception` broadly and accidentally swallowing `CancellationException`, breaking structured concurrency.
- Using `GlobalScope.launch` which leaks coroutines and ignores lifecycle.
- Collecting a `Flow` in `onCreate` without lifecycle awareness, causing collection to continue when the app is in the background.

## Quick Example

```kotlin
suspend fun fetchUser(userId: String): User {
    return withContext(Dispatchers.IO) {
        apiService.getUser(userId)
    }
}
```

```kotlin
suspend fun processImage(bitmap: Bitmap): Bitmap {
    return withContext(Dispatchers.Default) {
        applyFilter(bitmap)
    }
}
```
skilldb get android-kotlin-skills/CoroutinesFull skill: 220 lines
Paste into your CLAUDE.md or agent config

Coroutines and Flow — Android/Kotlin

You are an expert in Kotlin coroutines and Flow for building Android applications with Kotlin.

Core Philosophy

Kotlin coroutines bring structured concurrency to Android, meaning that concurrent work has a defined scope, a defined lifetime, and automatic cancellation when that scope ends. This is a fundamental shift from the old model of launching fire-and-forget AsyncTask or Thread instances that outlive their creators. When you launch a coroutine in viewModelScope, it is automatically cancelled when the ViewModel is cleared. When a lifecycleScope coroutine runs in a Fragment, it respects the Fragment's lifecycle. The structure is the safety net.

The dispatcher system (Main, IO, Default) is how coroutines avoid blocking threads. The key rule is simple: suspend functions should be main-safe. A function like fetchUser() should internally use withContext(Dispatchers.IO) for the network call, so the caller never needs to worry about which thread it runs on. This convention means you can call any suspend function from the main thread without fear, and the responsibility for thread-switching lives in the function that knows it needs it.

Flow is the reactive primitive for values that change over time. StateFlow holds the latest value and replays it to new collectors -- perfect for UI state. SharedFlow supports configurable buffering and replay -- useful for events. Raw Flow is cold, meaning it only runs when collected. Understanding these three types and when to use each is essential: StateFlow for "what is the current state," SharedFlow for "what happened," and Flow for "give me a stream of values on demand."

Anti-Patterns

  • Catching Exception broadly and swallowing CancellationException: A catch (e: Exception) block catches CancellationException, which breaks structured concurrency by preventing the coroutine from being properly cancelled. Always rethrow CancellationException, or use a more specific exception type in catch blocks.

  • Using GlobalScope for any purpose: GlobalScope coroutines have no parent scope, no lifecycle awareness, and no automatic cancellation. They leak work that runs indefinitely, consuming resources and potentially updating UI state long after the screen is gone. Use viewModelScope, lifecycleScope, or a custom scope with a SupervisorJob.

  • Collecting a Flow in onCreate without lifecycle awareness: Collecting a Flow in onCreate or onCreateView without using repeatOnLifecycle(Lifecycle.State.STARTED) means collection continues when the app is in the background, wasting resources and potentially causing crashes from UI updates in an inactive state.

  • Using runBlocking on the main thread: runBlocking blocks the current thread until the coroutine completes. On the main thread, this causes an ANR (Application Not Responding) if the blocked work takes more than a few seconds. It exists for bridging synchronous APIs in tests and main() functions, not for production Android code.

  • Creating new StateFlow on every recomposition: If a Composable function creates a stateIn conversion inline, a new StateFlow is created on every recomposition, losing state and restarting collection. Hoist stateIn calls into the ViewModel where they are created once and survive recomposition.

Overview

Kotlin coroutines provide structured concurrency for asynchronous programming on Android. Combined with Flow for reactive streams, they replace callbacks, RxJava chains, and AsyncTask with sequential, readable code that respects Android lifecycle constraints.

Core Concepts

Suspend Functions

A suspend function can pause and resume without blocking a thread.

suspend fun fetchUser(userId: String): User {
    return withContext(Dispatchers.IO) {
        apiService.getUser(userId)
    }
}

Coroutine Scopes and Structured Concurrency

Every coroutine runs in a scope. When a scope is cancelled, all its child coroutines are cancelled too.

class UserViewModel : ViewModel() {

    fun loadUser(userId: String) {
        viewModelScope.launch {
            try {
                val user = repository.fetchUser(userId)
                _uiState.value = UiState.Success(user)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

Dispatchers

  • Dispatchers.Main — UI thread, for updating views and state.
  • Dispatchers.IO — optimized for disk and network I/O.
  • Dispatchers.Default — CPU-intensive work (sorting, parsing).
suspend fun processImage(bitmap: Bitmap): Bitmap {
    return withContext(Dispatchers.Default) {
        applyFilter(bitmap)
    }
}

Flow Basics

Flow is a cold asynchronous stream that emits values sequentially.

fun observeUsers(): Flow<List<User>> = flow {
    while (true) {
        val users = apiService.getUsers()
        emit(users)
        delay(30_000)
    }
}

StateFlow and SharedFlow

StateFlow holds a single current value and replays it to new collectors. SharedFlow supports configurable replay and buffering.

class SearchViewModel : ViewModel() {

    private val _query = MutableStateFlow("")
    val query: StateFlow<String> = _query.asStateFlow()

    val searchResults: StateFlow<List<Result>> = _query
        .debounce(300)
        .filter { it.length >= 2 }
        .flatMapLatest { query -> repository.search(query) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun onQueryChanged(newQuery: String) {
        _query.value = newQuery
    }
}

Implementation Patterns

Parallel Decomposition

Run independent tasks concurrently with async and awaitAll.

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val userDeferred = async { repository.fetchUser() }
    val ordersDeferred = async { repository.fetchOrders() }
    val notificationsDeferred = async { repository.fetchNotifications() }

    Dashboard(
        user = userDeferred.await(),
        orders = ordersDeferred.await(),
        notifications = notificationsDeferred.await()
    )
}

Retry with Exponential Backoff

suspend fun <T> retryWithBackoff(
    times: Int = 3,
    initialDelay: Long = 1000,
    factor: Double = 2.0,
    block: suspend () -> T
): T {
    var currentDelay = initialDelay
    repeat(times - 1) {
        try {
            return block()
        } catch (e: Exception) {
            delay(currentDelay)
            currentDelay = (currentDelay * factor).toLong()
        }
    }
    return block() // last attempt — let exception propagate
}

Flow Operators

val filteredItems: Flow<List<Item>> = repository.observeItems()
    .map { items -> items.filter { it.isActive } }
    .distinctUntilChanged()
    .catch { e -> emit(emptyList()) }
    .flowOn(Dispatchers.Default)

Collecting Flows in Compose

@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when (val state = uiState) {
        is UiState.Loading -> CircularProgressIndicator()
        is UiState.Success -> UserContent(state.user)
        is UiState.Error -> ErrorMessage(state.message)
    }
}

Cancellation-Safe Resource Management

suspend fun downloadFile(url: String, destination: File) {
    withContext(Dispatchers.IO) {
        val response = httpClient.get(url)
        destination.outputStream().use { output ->
            response.bodyAsChannel().copyTo(output)
            ensureActive() // check cancellation before committing
        }
    }
}

Best Practices

  • Use viewModelScope in ViewModels and lifecycleScope in Activities/Fragments — never create unscoped GlobalScope coroutines.
  • Switch dispatchers with withContext rather than launching new coroutines on different dispatchers.
  • Use StateFlow with SharingStarted.WhileSubscribed(5000) to keep upstream alive briefly during configuration changes.
  • Prefer collectAsStateWithLifecycle() in Compose over plain collectAsState() to respect lifecycle.
  • Use supervisorScope or SupervisorJob when child failures should not cancel siblings.
  • Always handle CancellationException correctly — rethrow it, never swallow it.

Common Pitfalls

  • Catching Exception broadly and accidentally swallowing CancellationException, breaking structured concurrency.
  • Using GlobalScope.launch which leaks coroutines and ignores lifecycle.
  • Collecting a Flow in onCreate without lifecycle awareness, causing collection to continue when the app is in the background.
  • Forgetting flowOn — operators before flowOn run on the specified dispatcher, but collection still happens on the collector's dispatcher.
  • Creating a new StateFlow or SharedFlow on every recomposition instead of hoisting it in the ViewModel.
  • Using runBlocking on the main thread, causing an ANR.

Install this skill directly: skilldb add android-kotlin-skills

Get CLI access →