Coroutines & Flow
Suspending functions, scopes, dispatchers, Flow, StateFlow, SharedFlow, exception handling
- 01
What are coroutines and how do they differ from threads?
MediumCoroutines 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) … - 02
What are CoroutineScope, Job, and structured concurrency?
MediumStructured 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) … - 03
Which dispatcher do you pick for CPU work, blocking I/O and UI updates?
MediumA 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 … - 04
What is Flow? How is it different from suspending functions or Channel?
MediumA
Flow<T>is a cold asynchronous stream — like aSequence, 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) … - 05
What are StateFlow and SharedFlow? When to use which?
MediumStateFlow 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 { … - 06
How do you handle exceptions and cancellation in coroutines?
HardCancellation and exception handling are two separate mechanisms, and
CancellationExceptionis 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... } … - 07
Wire up a search box: the user types, and you must not fire one request per keystroke. Which Flow operators do the work?
MediumA search box needs
debounceto wait for a pause in typing,distinctUntilChangedto drop repeats, andflatMapLatestto 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 } … - 08
A Flow emits faster than the collector can consume. What happens by default, and how do you change it?
MediumBy default a flow is sequential —
emitsuspends 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 … - 09
What does the compiler turn a
suspendfunction into, and how do you wrap a callback-based API in one?HardA
suspendfunction compiles to a state machine that takes an extraContinuationparameter 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
How do you test a ViewModel that launches coroutines and exposes a StateFlow, without sleeping in the test?
MediumThe 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
A thousand coroutines increment the same counter and the total comes out wrong. How do you fix it without blocking a thread?
HardCoroutines 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
A screen needs three independent network calls before it can render. How do you run them in parallel, and what happens when one fails?
Mediumasyncstarts each call immediately andawaitAllwaits for the whole set, whilecoroutineScopeguarantees 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
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?
MediumviewModelScopeis cancelled inonCleared(), 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
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?
MediumA coroutine started in
lifecycleScopelives until the Activity or Fragment is destroyed, so a collector launched inonCreatekeeps 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
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?
MediumOne-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
Your
catchoperator never sees the error thrown by yourcollectblock, and a single timeout kills the stream for good — how do you build a resilient flow?Mediumcatchonly sees what happened UPSTREAM of it andretryre-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
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?
HardCache the in-flight
Deferred, not the result: the first caller starts oneasync, 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
After a rotation your repository flow restarts from scratch, and on another screen it never stops at all — what does SharingStarted actually control?
HardstateInandshareInlaunch one coroutine in the scope you give them that collects the cold upstream and re-broadcasts it, andSharingStarteddecides 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
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?
HardA 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
A colleague writes
scope.launch(SupervisorJob()) { ... }so one failure will not kill the siblings, and nowscope.cancel()leaves the work running — why?HardA
Jobin the context you pass to a builder REPLACES the scope's job as the new coroutine's parent, soscope.launch(SupervisorJob())creates an orphan thatscope.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 …