Skip to content
Commitwise
Menu
Android Development

Coroutines and Flow

Do background work without blocking the UI using suspend functions, scopes and dispatchers, and model data that changes over time with Flow and StateFlow on Android.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

“Application Not Responding.” Android shows that dialog when the main thread is blocked for about five seconds: a network call, a large file read, a slow database query done in the wrong place. The opposite bug is quieter: work started in the background that keeps running, and keeps the screen in memory, long after the user has left.

Kotlin coroutinesCoroutineA function that can pause and resume later without blocking a thread, used for async code in Kotlin.Open in glossary → solve both: suspend functions wait without blocking, and scopes tie background work to the lifetime of the screen or ViewModel that started it. Flow extends the same ideas to values that change over time.

New to some of these? Open what you need, or skip ahead.

Basics:What are threads and the main thread?

A thread is a line of work the computer runs; a program can run several at the same time. Apps have one special thread, the main thread (also called the UI thread), which draws the screen and handles taps.

If code on the main thread waits for something slow, like the network, the screen freezes: no scrolling, no animations. On Android, a few seconds of that shows the “App isn’t responding” dialog. That’s why slow work must happen without blocking the main thread.

Basics:What does asynchronous mean?

Synchronous code does one thing at a time and waits for each step to finish. Asynchronous code starts something slow (a download), lets other work continue in the meantime, and continues when the result is ready.

Swift (async/await), Kotlin (suspend functions in coroutines) and Java (virtual threads, futures) each have their own way to write asynchronous code that still reads top to bottom, like normal code. The waiting doesn’t freeze the app.

Basics:What is a ViewModel?

A ViewModel is a class that holds a screen’s data and logic, separate from the UI code. On Android it survives configuration changes: when the phone rotates and the Activity is recreated, the new screen gets the same ViewModel back, with its data still loaded.

It’s also the natural owner of background work for the screen: viewModelScope cancels that work automatically when the screen is closed for good.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What is a callback?

A callback is a function you hand to some other code so it can call you back later: “when the location changes, run this”, “when the download finishes, run that”. In Kotlin and Java it’s often an object implementing a listener interface, or a lambda; in Swift, a closure.

Callbacks work, but chaining several of them gets messy, and you must remember to unregister them when you no longer care. Coroutines, Flow and async/await were created largely to replace callback-based code with code that reads top to bottom.

Basics:What are databases, tables and SQL?

A database stores an app’s data so it survives restarts and can be searched quickly. A relational database (PostgreSQL, MySQL) keeps data in tables: rows (one per book, user or order) and columns (id, title, year).

You talk to it with SQL: SELECT title FROM books WHERE year > 2000 reads, INSERT, UPDATE and DELETE change data. Rows refer to other tables through foreign keys (book.author_id points to a row in author), and indexes make lookups by a column fast.

Part Not started

Suspending instead of blocking

Imagine a waiter taking an order to the kitchen. A blocking waiter stands at the kitchen door until the food is ready; meanwhile nobody else is served. A suspending waiter hands the order over, serves other tables, and comes back when the kitchen calls.

In code:

  • A suspend function can pause (for a network response, a timer, a database query) without blocking the thread. The main thread keeps drawing the UI in the meantime.
  • A coroutine is a piece of work that can suspend. You start one with launch.
  • A scope decides how long coroutines may live. viewModelScope cancels everything when the ViewModel is cleared; lifecycleScope when the screen is destroyed.
  • A dispatcher decides which threads run the code: Dispatchers.Main for UI, Dispatchers.IO for blocking input/output, Dispatchers.Default for heavy computation.

Coroutines are suspendable computations scheduled on dispatchers, organized by structured concurrencyStructured concurrencyOrganizing concurrent work so every task has a parent that waits for it, cancels it and receives its errors.Open in glossary →: every coroutine runs in a CoroutineScope, a parent waits for its children, cancellation flows down and failures flow up. suspend functions should be main-safe (callers on Dispatchers.Main must never block), which libraries like Retrofit and Room already are.

Flow is a cold, suspending stream; StateFlow and SharedFlow are hot ones for state and events. The senior topics are the edges of structure: exception propagation and supervisorScope, cooperative cancellation, callbackFlow lifetimes, sharing with stateIn/shareIn, backpressure, and testing with virtual time. All in the Detailed version.

sealed interface OrdersUiState {
    data object Loading : OrdersUiState
    data class Loaded(val orders: List<Order>) : OrdersUiState
    data class Error(val message: String) : OrdersUiState
}

class OrdersViewModel(private val repository: OrdersRepository) : ViewModel() {
    private val _state = MutableStateFlow<OrdersUiState>(OrdersUiState.Loading)
    val state: StateFlow<OrdersUiState> = _state.asStateFlow()

    init { refresh() }

    fun refresh() {
        viewModelScope.launch {                          // cancelled when the ViewModel is cleared
            _state.value = OrdersUiState.Loading
            _state.value = try {
                OrdersUiState.Loaded(repository.orders())  // suspends; the main thread stays free
            } catch (e: IOException) {
                OrdersUiState.Error("Check your connection and try again.")
            }
        }
    }
}

class OrdersRepository(private val api: OrdersApi) {
    suspend fun orders(): List<Order> = api.orders()    // a Retrofit suspend function: main-safe
}

viewModelScope.launch starts a coroutine on the main thread. When it reaches repository.orders(), it suspends: Retrofit does the network work on its own threads, the main thread goes back to drawing, and the coroutine continues on the main thread when the response arrives. If the user leaves and the ViewModel is cleared, the coroutine is cancelled, and so is the request.

Work that outlives its screen

Spot the bugA profile that keeps loading after you leave

Tap the line with the bug.

class ProfileViewModel(private val api: ProfileApi) : ViewModel() {
    private val _profile = MutableStateFlow<Profile?>(null)
    val profile: StateFlow<Profile?> = _profile.asStateFlow()

    fun load() {
        GlobalScope.launch(Dispatchers.Main) {
            _profile.value = api.profile()
        }
    }
}

launch doesn’t wait

launch starts a coroutine and returns immediately. The code after it keeps running:

Predict the output

What gets printed, in order?

fun main() = runBlocking {
    launch {
        delay(100)
        println("A")
    }
    println("B")
    delay(200)
    println("C")
}

To get a result from concurrent work, use async and await():

suspend fun loadDashboard(): Dashboard = coroutineScope {
    val profile = async { api.profile() }          // both start now
    val orders = async { api.orders() }
    Dashboard(profile.await(), orders.await())     // wait for both
}

coroutineScope waits for both children, and if one fails, cancels the other and rethrows. The two requests take as long as the slower one, not the sum.

Main-safe functions

Many APIs already suspend without blocking (Retrofit, Room, DataStore, Ktor). Code that blocks a thread (reading a file with File.readText(), a legacy SDK call, parsing a huge JSON) must move to another dispatcher:

class NotesRepository(
    private val file: File,
    private val io: CoroutineDispatcher = Dispatchers.IO,   // injected, so tests can replace it
) {
    suspend fun load(): List<Note> = withContext(io) {
        Json.decodeFromString(file.readText())              // blocking read, now on an IO thread
    }
}

withContext runs the block on the IO dispatcher and returns to the caller’s dispatcher with the result. The rule: a suspend function must be safe to call from the main thread, so the function that blocks is responsible for switching, not its callers.

Exceptions follow the structure

  • In coroutineScope (and a plain Job), a failing child cancels its siblings and the parent, and the exception is rethrown from coroutineScope. That’s what you want for “all or nothing” work.
  • In supervisorScope (and SupervisorJob, which viewModelScope and lifecycleScope use), a failing child doesn’t affect its siblings. Its exception goes to the CoroutineExceptionHandler, and with none installed, to the thread’s uncaught exception handler, which crashes an Android app.
  • launch propagates failures immediately; async holds the exception until await(), but in a non-supervisor scope it still cancels the parent at once. An async whose result is never awaited in a supervisor scope swallows its error.
  • CoroutineExceptionHandler only works on root coroutines (or direct children of a supervisor); on a nested launch it’s ignored.

Cancellation is cooperative

Cancelling a coroutine sets a flag and throws CancellationException at the next suspension point that checks it. A loop that never suspends (parsing, image processing) runs to the end unless it calls ensureActive() or yield(). Cleanup that must itself suspend after cancellation (sending “upload aborted”) needs withContext(NonCancellable). And any broad catch (e: Exception) must rethrow CancellationException.

One failure, two coroutines

Predict the output

What gets printed?

fun main() = runBlocking {
    try {
        coroutineScope {
            launch {
                delay(50)
                throw IllegalStateException("boom")
            }
            launch {
                delay(100)
                println("B done")
            }
        }
    } catch (e: IllegalStateException) {
        println("caught ${e.message}")
    }
}
  • Structured concurrency: parents wait for children, cancellation flows down, failures flow up. coroutineScope is all-or-nothing; supervisorScope/SupervisorJob isolate children, and an unhandled failure there crashes an Android app.
  • CoroutineExceptionHandler works only on root coroutines; rethrow CancellationException from broad catches; long loops need ensureActive().

Check this part

  1. What does a suspend function do while waiting for a network response?

  2. Which scope should a ViewModel use for its coroutines?

  3. A child launched in supervisorScope throws and no handler is installed. What happens on Android?

  4. A cancelled coroutine runs a CPU loop with no suspension points. When does it stop?

A suspend function waits without blocking the thread; scopes like viewModelScope tie coroutines to a lifetime and cancel them; dispatchers pick the threads. Blocking code switches with withContext, so every suspend function is main-safe.

Part Not started

Data that changes: Flow

A suspend function returns one value. A Flow delivers many over time: every change to a database table, every location update, every keystroke in a search field.

@Dao
interface MessageDao {
    @Query("SELECT * FROM messages")
    fun observeAll(): Flow<List<Message>>                // emits again whenever the table changes
}

class InboxViewModel(dao: MessageDao) : ViewModel() {
    val unreadCount: StateFlow<Int> = dao.observeAll()
        .map { messages -> messages.count { !it.isRead } }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initialValue = 0)
}

@Composable
fun InboxBadge(viewModel: InboxViewModel) {
    val count by viewModel.unreadCount.collectAsStateWithLifecycle()
    if (count > 0) Badge { Text("$count") }
}

Read it as a pipeline: the database emits the list of messages, map turns each list into a number, and stateIn turns the flow into a StateFlow with a current value the UI can show. collectAsStateWithLifecycle() collects it only while the screen is visible. Mark a message as read anywhere in the app, and the badge updates by itself.

Practice: a search pipeline

Search-as-you-type is the classic Flow example. Step by step:

  1. Hold the query in a MutableStateFlow, updated on every keystroke.
  2. debounce(300): wait until the user pauses typing for 300 ms.
  3. distinctUntilChanged(): skip if the query didn’t actually change.
  4. flatMapLatest: start a search for the new query and cancel the previous one if it’s still running.
  5. stateIn: expose the results as a StateFlow for the UI.
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
class SearchViewModel(private val repository: SearchRepository) : ViewModel() {
    private val query = MutableStateFlow("")

    val results: StateFlow<List<Product>> = query
        .debounce(300)
        .map { it.trim() }
        .distinctUntilChanged()
        .flatMapLatest { q ->
            if (q.length < 2) flowOf(emptyList())
            else flow { emit(repository.search(q)) }
                .catch { emit(emptyList()) }           // a failed search doesn't kill the pipeline
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun onQueryChange(newQuery: String) {
        query.value = newQuery
    }
}

Notice where catch is: inside flatMapLatest, around one search. A catch at the end of the whole chain would also handle the error, but the flow would be finished afterwards, and the next keystroke would search nothing.

And a bug from the same family of mistakes:

Spot the bugNotes that freeze the screen

Tap the line with the bug.

class NotesViewModel(private val notesFile: File) : ViewModel() {
    private val _notes = MutableStateFlow<List<Note>>(emptyList())
    val notes: StateFlow<List<Note>> = _notes.asStateFlow()

    init {
        viewModelScope.launch {
            val text = notesFile.readText()
            _notes.value = Json.decodeFromString(text)
        }
    }
}

Exerciseeasy

A dashboard from two requests

A dashboard needs the user’s profile (api.profile()) and their recent orders (api.recentOrders()), both suspend functions taking about one second. Write a ViewModel that:

  • loads both in parallel,
  • exposes a StateFlow with Loading, Ready(profile, orders) or Error(message),
  • offers retry(),
  • shows the error state if either request fails.
›

Use coroutineScope { } with two async blocks inside the launch. If one throws, coroutineScope cancels the other and rethrows, so one try/catch around it handles both.

›

Model the state as a sealed interface, and make retry() call the same load() function that init uses.

SolutionTry it yourself first
sealed interface DashboardUiState {
    data object Loading : DashboardUiState
    data class Ready(val profile: Profile, val orders: List<Order>) : DashboardUiState
    data class Error(val message: String) : DashboardUiState
}

class DashboardViewModel(private val api: ShopApi) : ViewModel() {
    private val _state = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
    val state: StateFlow<DashboardUiState> = _state.asStateFlow()

    init { load() }

    fun retry() = load()

    private fun load() {
        viewModelScope.launch {
            _state.value = DashboardUiState.Loading
            _state.value = try {
                coroutineScope {
                    val profile = async { api.profile() }
                    val orders = async { api.recentOrders() }
                    DashboardUiState.Ready(profile.await(), orders.await())
                }
            } catch (e: IOException) {
                DashboardUiState.Error("Couldn't load your dashboard.")
            } catch (e: HttpException) {
                DashboardUiState.Error("Something went wrong on our side.")
            }
        }
    }
}

Both requests start at the same moment, so the screen is ready after about one second instead of two. Catching specific exceptions (not Exception) matters here: a broad catch would also catch CancellationException when the ViewModel is cleared mid-load, and try to show an error on a screen that’s gone.

Sharing flows

  • Cold flows run their producer once per collector: two screens collecting the same flow { api.poll() } poll twice. stateIn/shareIn turn a cold flow into one shared hot flow.
  • SharingStarted.WhileSubscribed(5_000) stops the upstream 5 seconds after the last collector leaves: long enough to survive a configuration change without restarting, short enough to stop work when the app goes to the background. Eagerly and Lazily never stop.
  • Backpressure: a slow collector suspends the producer by default. buffer() decouples them, conflate() keeps only the latest value, and collectLatest cancels slow processing when a new value arrives (right for rendering, wrong for writes).
  • flowOn(dispatcher) changes the dispatcher upstream of it; never use withContext inside flow { } to emit from another context (it throws).

Testing

runTest runs coroutines on virtual time: delay(5_000) completes instantly while keeping order. Inject dispatchers (never hard-code Dispatchers.IO), replace Dispatchers.Main with a test dispatcher via Dispatchers.setMain (a JUnit rule), and assert flows with Turbine (flow.test { assertEquals(Loading, awaitItem()) … }), including debounce timing with advanceTimeBy.

A callback API as a Flow

Spot the bugLocation updates that stop immediately

Tap the line with the bug.

fun FusedLocationProviderClient.locationUpdates(request: LocationRequest): Flow<Location> = callbackFlow {
    val callback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult) {
            result.lastLocation?.let { trySend(it) }
        }
    }
    requestLocationUpdates(request, callback, Looper.getMainLooper())
}

Exercisemedium

Polling that stops when nobody watches

A stock screen shows a live price. The API has no push; you poll api.price(symbol) every 5 seconds. Requirements:

  • polling runs only while the price is on screen (and survives rotation without restarting),
  • network errors retry with exponential backoff (1, 2, 4, 8, 16 s), then the UI shows “Price unavailable”,
  • two screens showing the same symbol share one poller,
  • it’s testable without waiting real seconds.
›

A cold flow { while (true) { emit(api.price(symbol)); delay(interval) } } is the poller. retryWhen adds backoff. Who decides when it runs?

›

stateIn(scope, SharingStarted.WhileSubscribed(5_000), Loading) starts the cold flow with the first collector, shares it, and stops it 5 seconds after the last one leaves. Inject the delay or use runTest’s virtual time for tests.

SolutionTry it yourself first
class PriceRepository(
    private val api: PriceApi,
    private val scope: CoroutineScope,                       // application scope, injected
    private val interval: Duration = 5.seconds,
) {
    private val cache = ConcurrentHashMap<String, StateFlow<PriceState>>()

    /** One shared, lifecycle-aware poller per symbol. */
    fun price(symbol: String): StateFlow<PriceState> = cache.getOrPut(symbol) {
        flow {
            while (true) {
                emit(PriceState.Live(api.price(symbol)))
                delay(interval)
            }
        }
            .retryWhen { cause, attempt ->
                if (cause is IOException && attempt < 5) {
                    delay(1.seconds * (1 shl attempt.toInt()))   // 1, 2, 4, 8, 16 s
                    true
                } else {
                    false
                }
            }
            .catch { emit(PriceState.Unavailable) }
            .stateIn(scope, SharingStarted.WhileSubscribed(5_000), PriceState.Loading)
    }
}

sealed interface PriceState {
    data object Loading : PriceState
    data class Live(val price: Price) : PriceState
    data object Unavailable : PriceState
}

@Composable
fun PriceLabel(repository: PriceRepository, symbol: String) {
    val state by remember(symbol) { repository.price(symbol) }.collectAsStateWithLifecycle()
    when (val s = state) {
        PriceState.Loading -> CircularProgressIndicator()
        is PriceState.Live -> Text(s.price.formatted())
        PriceState.Unavailable -> Text("Price unavailable")
    }
}

// Test: virtual time, no real waiting.
@Test
fun retriesWithBackoffThenGivesUp() = runTest {
    val api = FakePriceApi(failures = Int.MAX_VALUE)
    val repo = PriceRepository(api, backgroundScope)
    repo.price("ACME").test {
        assertEquals(PriceState.Loading, awaitItem())
        advanceTimeBy(1.seconds + 2.seconds + 4.seconds + 8.seconds + 16.seconds + 1.milliseconds)
        assertEquals(PriceState.Unavailable, awaitItem())
        assertEquals(6, api.calls)                             // 1 try + 5 retries
    }
}

How the requirements map to operators:

  • Runs only while watched: WhileSubscribed starts the cold poller with the first collector and cancels it 5 s after the last; collectAsStateWithLifecycle stops collecting when the app goes to the background. Rotation takes under 5 s, so the poller isn’t restarted.
  • Shared: the repository keeps one StateFlow per symbol in an application scope, so two screens (or a widget) share a poller. With a per-ViewModel stateIn, each screen would poll separately.
  • Backoff: retryWhen restarts the whole upstream flow after a delay; add jitter in production so thousands of clients don’t retry in lockstep after an outage. catch after it turns the final failure into a state instead of an exception.
  • Testable: runTest skips delays in virtual time and backgroundScope cancels the shared flow at the end of the test. The repository never hard-codes a dispatcher.
  • Not shown but worth saying: the cache never shrinks (fine for a handful of symbols; use weak values or a bounded map for many), and a WebSocket or server-sent events would beat polling if the backend offers them.

Deep dives

Deep diveStateFlow skips equal valuesInterview favorite

StateFlow is conflated (a slow collector sees only the latest value) and compares values with equals: setting the same value again emits nothing. That’s usually what you want for UI state, and it causes a classic bug: mutating a list in place and assigning the same instance back (_items.value = items.apply { add(x) }) doesn’t notify anyone, because the old and new values are the same object.

Always emit a new immutable value (_items.update { it + x }). When every emission matters, including repeats (events, or values that must all be processed), use a SharedFlow or a Channel instead.

NeedTool
Start work from a ViewModel / screenviewModelScope.launch, lifecycleScope + repeatOnLifecycle
Parallel resultscoroutineScope { async {…}; async {…} } then await()
Independent childrensupervisorScope / SupervisorJob + CoroutineExceptionHandler on roots
Blocking codewithContext(Dispatchers.IO) inside the function that blocks (main-safety)
State for UIStateFlow, stateIn(scope, WhileSubscribed(5_000), initial)
EventsChannel + receiveAsFlow() or SharedFlow(replay = 0)
Callback APIscallbackFlow { …; awaitClose { unregister() } }
Backpressurebuffer, conflate, collectLatest, flatMapLatest
TestsrunTest, injected dispatchers, Dispatchers.setMain, Turbine

Gotchas: GlobalScope, blocking calls inside launch, catching CancellationException, catch at the end of a long-lived flow ends it, callbackFlow without awaitClose, cold flows collected twice run twice, CoroutineExceptionHandler on non-root coroutines is ignored.

class ReportViewModel(
    private val repo: ReportRepository,
) : ViewModel() {
    private val _state = MutableStateFlow<ReportState>(ReportState.Idle)
    val state = _state.asStateFlow()
    private var job: Job? = null

    fun generate(month: YearMonth) {
        job?.cancel()                                     // a new request replaces the old one
        job = viewModelScope.launch {
            _state.value = ReportState.Working
            _state.value = try {
                ReportState.Done(repo.build(month))       // main-safe: withContext inside repo
            } catch (e: CancellationException) {
                throw e
            } catch (e: IOException) {
                ReportState.Failed
            }
        }
    }
}

Check this part

  1. What is the difference between a suspend function and a Flow?

  2. Where should a blocking call like file.readText() run?

  3. Why use SharingStarted.WhileSubscribed(5_000) with stateIn?

  4. What must every callbackFlow end with?

A suspend function returns one value; a Flow delivers many over time. stateIn turns a flow into StateFlow for the UI, a search pipeline uses debounce and flatMapLatest, and sharing, callbackFlow and backpressure decide what runs and for how long.

Key takeaways

  • suspend functions wait without blocking; coroutines started in viewModelScope are cancelled with the ViewModel.
  • Use async/await inside coroutineScope to run independent work in parallel.
  • Blocking calls go inside withContext(Dispatchers.IO); a suspend function must be safe to call from the main thread.
  • Use Flow for values that change over time, expose StateFlow to the UI and collect it with collectAsStateWithLifecycle().
  • Structured concurrency: failures cancel siblings in coroutineScope, not in supervisorScope; handlers only work on roots.
  • Cancellation is cooperative and travels as CancellationException: check it in CPU loops and never swallow it.
  • Share cold flows with stateIn/shareIn and WhileSubscribed(5_000); choose buffer, conflate or collectLatest deliberately.
  • Wrap callbacks with callbackFlow + awaitClose, inject dispatchers, and test with runTest and virtual time.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.