Coroutines & Flow
Irbisa · cheatsheetSeptember 13, 2026

Coroutines & Flow

Suspending functions, scopes, dispatchers, Flow, StateFlow, SharedFlow, exception handling

Middle Developer20 itemscompressed for a skim
  1. 01

    What are coroutines and how do they differ from threads?

    Medium

    Coroutines are lightweight concurrency primitives that suspend without blocking the underlying thread.

    import kotlinx.coroutines.*
    
    // suspend function — looks sequential, never blocks the thread
    suspend fun loadUser(id: Long): User {
      delay(100)                // not blocking — schedules a resume
      return api.fetchUser(id)
    …
  2. 02

    What are CoroutineScope, Job, and structured concurrency?

    Medium

    Structured concurrency means every coroutine has a parent that owns it, waits for it and can cancel it.

    import kotlinx.coroutines.*
    
    class FeedRepository {
      // Lifetime tied to this object — cancel scope when object goes away
      private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    …
  3. 03

    Which dispatcher do you pick for CPU work, blocking I/O and UI updates?

    Medium

    A CoroutineDispatcher decides which thread or thread pool a coroutine runs on.

    suspend fun loadAndParse(url: String): Report {
      val raw: String = withContext(Dispatchers.IO) {
        URL(url).readText()                 // blocking — belongs on IO
      }
      return withContext(Dispatchers.Default) {
        parse(raw)                          // CPU-bound — belongs on Default
    …
  4. 04

    What is Flow? How is it different from suspending functions or Channel?

    Medium

    A Flow<T> is a cold asynchronous stream — like a Sequence, except its producer is allowed to suspend.

    // Cold flow — the builder runs once per collector
    fun ticker(): Flow<Int> = flow {
      var i = 0
      while (true) {
        emit(i++)
        delay(1000)
    …
  5. 05

    What are StateFlow and SharedFlow? When to use which?

    Medium

    StateFlow always holds a current value and replays it to every new collector, while SharedFlow is a configurable broadcast hub with no value of its own.

    // State — current snapshot plus future updates
    class HomeVM : ViewModel() {
      private val _ui = MutableStateFlow<UiState>(UiState.Loading)
      val ui: StateFlow<UiState> = _ui.asStateFlow()
    
      fun load() = viewModelScope.launch {
    …
  6. 06

    How do you handle exceptions and cancellation in coroutines?

    Hard

    Cancellation and exception handling are two separate mechanisms, and CancellationException is the one you must never swallow.

    // Cooperative cancellation
    suspend fun longCompute() = withContext(Dispatchers.Default) {
      repeat(1_000_000) { i ->
        if (i % 1000 == 0) ensureActive()  // throws CancellationException if cancelled
        // work...
      }
    …
  7. 07

    Wire up a search box: the user types, and you must not fire one request per keystroke. Which Flow operators do the work?

    Medium

    A search box needs debounce to wait for a pause in typing, distinctUntilChanged to drop repeats, and flatMapLatest to cancel the request the next keystroke made obsolete.

    class SearchViewModel(private val repo: SearchRepo) : ViewModel() {
      private val query = MutableStateFlow("")
      private val onlyInStock = MutableStateFlow(false)
    
      fun onQueryChange(text: String) { query.value = text }
    …
  8. 08

    A Flow emits faster than the collector can consume. What happens by default, and how do you change it?

    Medium

    By default a flow is sequential — emit suspends until the collector has finished with the previous value, so the producer runs at the collector's pace and nothing is ever dropped.

    // Default: producer waits for the collector, total time is the sum
    flow {
      repeat(3) { emit(load(it)) }        // 100 ms each
    }.collect { render(it) }              // 200 ms each  -> ~900 ms
    
    // buffer(): the two run concurrently -> ~600 ms
    …
  9. 09

    What does the compiler turn a suspend function into, and how do you wrap a callback-based API in one?

    Hard

    A suspend function compiles to a state machine that takes an extra Continuation parameter and returns a marker value when it needs to pause.

    suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
      enqueue(object : Callback {
        override fun onResponse(call: Call, response: Response) {
          cont.resume(response)                       // resume exactly once
        }
        override fun onFailure(call: Call, e: IOException) {
    …
  10. 10

    How do you test a ViewModel that launches coroutines and exposes a StateFlow, without sleeping in the test?

    Medium

    The kotlinx-coroutines-test library swaps in a scheduler with a virtual clock, so delays complete instantly and in a deterministic order.

    class MainDispatcherRule(
      private val dispatcher: TestDispatcher = StandardTestDispatcher(),
    ) : TestWatcher() {
      override fun starting(d: Description) = Dispatchers.setMain(dispatcher)
      override fun finished(d: Description) = Dispatchers.resetMain()
    }
    …
  11. 11

    A thousand coroutines increment the same counter and the total comes out wrong. How do you fix it without blocking a thread?

    Hard

    Coroutines give you no thread safety for free, because the default dispatcher runs them in parallel across as many threads as the device has cores.

    // ❌ Lost updates — Default runs these in parallel
    var counter = 0
    withContext(Dispatchers.Default) {
      repeat(1_000) { launch { counter++ } }
    }
    println(counter)              // usually less than 1000
    …
  12. 12

    A screen needs three independent network calls before it can render. How do you run them in parallel, and what happens when one fails?

    Medium

    async starts each call immediately and awaitAll waits for the whole set, while coroutineScope guarantees that a failure cancels the siblings before it propagates.

    suspend fun loadDashboard(): Dashboard = coroutineScope {
      val user = async { api.user() }
      val feed = async { api.feed() }
      val badges = async { api.badges() }
      Dashboard(user.await(), feed.await(), badges.await())   // all three overlap
    }
    …
  13. 13

    The user taps Save and immediately backs out of the screen, and the request never reaches the server — why, and where should that work live?

    Medium

    viewModelScope is cancelled in onCleared(), and that cancellation propagates straight into the suspending HTTP call, so work the user must not lose cannot be owned by the screen that started it.

    // ❌ Tied to the screen — backing out cancels the POST mid-flight
    class EditVM(private val repo: NoteRepo) : ViewModel() {
      fun save(note: Note) = viewModelScope.launch { repo.upload(note) }
    }
    
    // ✅ The repository owns a scope that outlives any screen
    …
  14. 14

    Logcat shows your chat socket still delivering messages while the app sits in the background, and the collector was started in onCreate — what went wrong?

    Medium

    A coroutine started in lifecycleScope lives until the Activity or Fragment is destroyed, so a collector launched in onCreate keeps the upstream hot for as long as the screen exists — background included.

    // ❌ Starts at onCreate, ends at onDestroy — the socket stays subscribed
    // behind a screen nobody is looking at
    lifecycleScope.launch {
      vm.messages.collect { render(it) }
    }
    …
  15. 15

    A navigation event fires twice after a rotation and another one vanishes while the screen is in the background — how do you model one-shot events?

    Medium

    One-shot events need a stream that buffers while nobody is listening and hands each value to exactly one consumer — that is a Channel, not a StateFlow and not a bare SharedFlow.

    // ❌ State replays: after a rotation the last route is delivered again
    private val _nav = MutableStateFlow<Route?>(null)
    
    // ❌ No buffer, no subscriber: the event is emitted into the void
    private val _lost = MutableSharedFlow<UiEvent>()   // replay 0, capacity 0
    fun onSaved() { _lost.tryEmit(UiEvent.Close) }     // returns false, dropped
    …
  16. 16

    Your catch operator never sees the error thrown by your collect block, and a single timeout kills the stream for good — how do you build a resilient flow?

    Medium

    catch only sees what happened UPSTREAM of it and retry re-collects that same upstream, so where you put them in the chain is the entire answer.

    val feed: StateFlow<FeedState> =
      repo.feedFlow()                                     // cold, may throw
        .retryWhen { cause, attempt ->
          if (cause !is IOException || attempt >= 3) return@retryWhen false
          delay(200L * (1 shl attempt.toInt()) + Random.nextLong(100))  // backoff
          true                                            // re-collect upstream
    …
  17. 17

    Five screens ask for data at once and the log shows five identical token refreshes — how do you make concurrent callers share one in-flight request?

    Hard

    Cache the in-flight Deferred, not the result: the first caller starts one async, everybody else awaits the same handle, and the other four requests never happen.

    class TokenStore(
      private val api: AuthApi,
      private val scope: CoroutineScope,        // application-scoped, NOT a caller's
    ) {
      private val mutex = Mutex()
      private var cached: Token? = null
    …
  18. 18

    After a rotation your repository flow restarts from scratch, and on another screen it never stops at all — what does SharingStarted actually control?

    Hard

    stateIn and shareIn launch one coroutine in the scope you give them that collects the cold upstream and re-broadcasts it, and SharingStarted decides exactly one thing: when that coroutine subscribes to the upstream and when it unsubscribes.

    class NotesVM(repo: NoteRepo) : ViewModel() {
      // ✅ Built once — one hot StateFlow for the life of the ViewModel
      val notes: StateFlow<List<Note>> = repo.notesFlow()          // cold Room query
        .stateIn(
          scope = viewModelScope,
          started = SharingStarted.WhileSubscribed(5_000),
    …
  19. 19

    A production crash inside a coroutine has a stack trace that stops at the dispatcher and never names the code that started the work — why, and what do you do about it?

    Hard

    A suspended coroutine has no thread stack — it is a heap object holding a label and its saved locals — so when it resumes on a pool thread the JVM stack contains only the resume path, not the call site that launched it.

    // The trace you get: everything above the last suspension point is gone
    // java.net.SocketTimeoutException: timeout
    //   at okhttp3.internal.connection.RealCall...
    //   at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:104)
    //   <- no sign of the screen or use case that started this
    …
  20. 20

    A colleague writes scope.launch(SupervisorJob()) { ... } so one failure will not kill the siblings, and now scope.cancel() leaves the work running — why?

    Hard

    A Job in the context you pass to a builder REPLACES the scope's job as the new coroutine's parent, so scope.launch(SupervisorJob()) creates an orphan that scope.cancel() can no longer reach.

    val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
    
    // ❌ The passed Job becomes the parent: this is NOT a child of `scope`
    val orphan = scope.launch(SupervisorJob()) { longRunningSync() }
    scope.cancel()          // orphan keeps running; only orphan.cancel() stops it
    …