Kotlin Coroutines Interview Questions
Coroutine questions that come up in almost every Android interview: suspend functions, scopes and structured concurrency, dispatchers, cancellation, exception handling, Channels, StateFlow and SharedFlow, testing, and real scenarios such as parallel API calls and polling.
By Vivek Choudhary, Android engineer · Updated October 2026 · How answers are checked
All 50 questions
- What is a Kotlin coroutine? How is it different from a thread? Most asked
- What are Kotlin coroutine Dispatchers? Explain IO, Main, Default, and Unconfined. Most asked
- What is structured concurrency? What is a CoroutineScope? Most asked
- What is the difference between launch and async? Most asked
- What is coroutine cancellation? How does it work and how do you make code cancellation-safe? Most asked
- What is the difference between Job and SupervisorJob? When do you use each? Most asked
- What is exception handling in coroutines? How do CoroutineExceptionHandler and try-catch differ? Most asked
- What is Kotlin Flow? How is it different from LiveData and RxJava? Most asked
- What is the difference between StateFlow and SharedFlow? When do you use each? Most asked
- Explain the key Flow operators — map, filter, transform, zip, combine, flatMapLatest. Most asked
- What is withContext and how is it different from launch/async? Most asked
- What is cold vs hot flow? How do stateIn and shareIn work?
- What are Kotlin Channels? Explain Channel types and how they differ from Flow.
- What are coroutine contexts and how do they compose? Most asked
- What is the Flow lifecycle? Explain onStart, onCompletion, and onEach.
- What is backpressure in Flow? How does Kotlin Flow handle it?
- How do you test coroutines and Flow? Explain TestCoroutineDispatcher and Turbine. Most asked
- What is the difference between suspend fun and regular fun? How does the compiler transform suspend functions?
- What is the difference between collect, collectLatest, and launchIn for Flow collection? Most asked
- What is mutex and how do you handle concurrent state updates safely?
- What are the main threading rules in Android and how do coroutines help? Most asked
- What is the purpose of viewModelScope, lifecycleScope, and viewLifecycleOwner.lifecycleScope? Most asked
- What is a coroutine flow vs a sequence? When would you use Sequence over Flow?
- How do you convert callback-based APIs to coroutines using suspendCoroutine and suspendCancellableCoroutine? Most asked
- Scenario: Your app makes 3 API calls — user, orders, and recommendations. Some can run in parallel. How do you structure this?
- Scenario: A network request is taking too long. How do you implement a timeout in coroutines?
- Scenario: You need to retry a failed network call with exponential backoff. How do you implement this with coroutines?
- What is Flow.debounce and Flow.throttle? Give real-world examples. Most asked
- Scenario: You need to implement a polling mechanism — check server every 5 seconds for order status. How do you implement this cleanly?
- What is the difference between coroutines and threads in terms of memory and scheduling? Most asked
- What is the actor pattern in coroutines? How does it help with concurrent state?
- What is Flow.zip vs Flow.combine? When do you choose each? Most asked
- Scenario: Your app does a lot of image processing on photos selected from gallery. The UI freezes. How do you fix it using coroutines?
- What is coroutine flow's catch operator vs try-catch? How do you handle errors in a Flow chain? Most asked
- What is structured concurrency's parent-child relationship? How does exception propagation work?
- What is Dispatchers.Main.immediate and when do you need it? Most asked
- Scenario: You need to upload 50 files. Show progress per file, support cancellation, and don't crash if one fails.
- What is the difference between coroutineScope and runBlocking? Most asked
- Scenario: Your app should load cached data immediately, then refresh from network in background. How do you implement this?
- What is limitedParallelism? How do you control coroutine concurrency?
- How does Flow work with Room database? Explain the reactive database pattern. Most asked
- Scenario: You have a coroutine that's leaking — it keeps running after the screen is dismissed. How do you diagnose and fix this?
- What is the difference between delay() and Thread.sleep() in coroutines? Most asked
- Scenario: Implement a real-time chat using WebSocket with automatic reconnection using Flow.
- What is the difference between launch(Dispatchers.IO) and withContext(Dispatchers.IO)? Most asked
- What are coroutine flow transformations — mapLatest, transformLatest, and their use cases?
- What is the coroutine Job hierarchy? How do you use joinAll and cancelAndJoin? Most asked
- Scenario: Design a download manager that downloads multiple files, shows progress, supports pause and resume, and retries on failure.
- What are the latest coroutine improvements in Kotlin 2024-2025?
- Scenario: You're reviewing a PR and find this code — what's wrong and how do you fix it?
1. What is a Kotlin coroutine? How is it different from a thread?
A coroutine is a suspendable computation — it can pause and resume without blocking the underlying thread. Unlike threads, thousands of coroutines can run on just a few threads, making them lightweight and efficient.
// Thread — blocks OS thread while waiting Thread { Thread.sleep(1000) // thread blocked, OS context switch updateUi() // crash if not on main thread }.start() // Coroutine — suspends without blocking thread viewModelScope.launch { delay(1000) // thread RELEASED — can do other work updateUi() // safe — resumes on correct dispatcher } // Scale comparison: // 10,000 threads → ~100MB RAM, OS scheduler thrash // 10,000 coroutines → ~few MB, cooperative scheduling // Coroutines are NOT threads: // Coroutines run ON threads (via Dispatchers) // Multiple coroutines share the same thread pool // Suspension = coroutine pauses, thread picks up another coroutine suspend fun example() { delay(1000) // suspends coroutine — doesn't block thread fetchData() // suspends here too — thread free meanwhile }
The fundamental difference is what happens when work needs to wait. A thread is an OS-level resource — when it calls Thread.sleep() or waits on IO, that thread is completely blocked: it cannot do anything else, yet it still consumes memory (~1MB stack) and scheduler time. Threads are preemptively scheduled by the OS, which means context switching between thousands of them is expensive. Creating 10,000 threads will exhaust memory and crash the JVM.
A coroutine is a suspendable computation. When it hits a suspension point — like delay() or a network call — it doesn't block the thread. Instead, it saves its state (local variables, execution position) and releases the thread back to the pool. That same thread immediately picks up another coroutine. When the awaited result is ready, the original coroutine is resumed on a thread — potentially a different one. This is cooperative scheduling, not preemptive OS scheduling.
In practice, this means you can run tens of thousands of coroutines on just a handful of threads. Each coroutine costs roughly a few hundred bytes of heap for its state, compared to ~1MB per thread. The suspend modifier is the compile-time contract that marks where a coroutine may pause — and the key distinction to emphasise in interviews is that coroutines run on threads via Dispatchers; they don't eliminate threads. They eliminate blocking threads.
Key phrase: "Coroutines don't replace threads — they run ON threads via Dispatchers. What they eliminate is BLOCKING threads. When a coroutine suspends, the thread picks up another coroutine instead of waiting idle."
2. What are Kotlin coroutine Dispatchers? Explain IO, Main, Default, and Unconfined.
Dispatchers determine which thread or thread pool a coroutine runs on. Choosing the correct Dispatcher is fundamental to avoiding ANRs and crashes.
// Dispatchers.Main — Android main/UI thread viewModelScope.launch(Dispatchers.Main) { textView.text = "Updated!" // safe — on main thread } // Dispatchers.IO — optimised for blocking I/O // Backed by up to 64 threads (or more on multicore) withContext(Dispatchers.IO) { val data = api.fetchUser() // network call val rows = db.query() // database read File("log.txt").readText() // file read } // Dispatchers.Default — CPU-intensive work // Backed by CPU core count threads withContext(Dispatchers.Default) { list.sortedBy { it.name } // sorting large list computeHeavyAlgorithm() // encryption, image processing parseHugeJson() // heavy parsing } // Dispatchers.Unconfined — inherits caller's thread // Resumes on whatever thread the suspension point resumes on // Rarely used — mainly for testing // Typical Android pattern suspend fun getUser(id: String): User = withContext(Dispatchers.IO) { api.getUser(id) // IO dispatcher for network } viewModelScope.launch { // Main by default val user = getUser("123") // suspends on IO _state.value = UiState.Success(user) // back on Main }
Dispatchers determine which thread pool a coroutine runs on. Choosing the wrong one is one of the most common Android bugs — using the Main dispatcher for a database query causes an ANR; doing UI updates off Main crashes with CalledFromWrongThreadException.
Dispatchers.Main runs on Android's single UI thread. This is where you read and write to Views, observe LiveData, or update StateFlow that drives Compose recomposition. It should never run blocking work. Dispatchers.IO is backed by a thread pool that can scale up to 64 threads (or more on higher core-count devices). It's designed for waiting work — network calls, database queries, file reads — where most time is spent idle waiting for data, not burning CPU. More threads means more parallelism while waiting.
Dispatchers.Default uses a pool sized to the number of CPU cores. It's for CPU-bound work — sorting large lists, JSON parsing, encryption, image processing — where adding more threads than cores just causes context-switch overhead. Dispatchers.Unconfined has no thread confinement; it runs wherever the coroutine is resumed, making it unpredictable in production but useful in tests. The key tool for switching is withContext() — it doesn't create a new coroutine, just switches the current one to a different dispatcher and switches back when the block completes.
IO vs Default is a common interview question. IO: many threads because most time is spent waiting (network latency). Default: few threads (CPU cores) because work is CPU-bound — more threads just causes context switching overhead.
3. What is structured concurrency? What is a CoroutineScope?
Structured concurrency means coroutines live within a defined scope — they're started, managed, and cancelled as a group. This prevents orphaned coroutines and resource leaks.
// Without structured concurrency — LEAKED coroutine class BadViewModel { fun load() { GlobalScope.launch { api.fetchData() } // Coroutine NEVER cancelled — lives until app death // ViewModel cleared → coroutine still running! } } // With structured concurrency — scoped lifecycle class GoodViewModel : ViewModel() { fun load() { viewModelScope.launch { api.fetchData() } // Cancelled automatically when ViewModel cleared } } // CoroutineScope = owner of coroutines + CoroutineContext // All children coroutines inherit parent's scope // Rules of structured concurrency: // 1. Parent waits for ALL children to complete // 2. Parent cancellation cancels ALL children // 3. Child failure propagates to parent (by default) val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) scope.launch { val a = async { fetchA() } val b = async { fetchB() } process(a.await (), b.await ()) // scope ensures A and B are cancelled if scope is cancelled } scope.cancel() // cancels all children // Android scopes // viewModelScope — tied to ViewModel lifecycle // lifecycleScope — tied to Activity/Fragment lifecycle // viewLifecycleScope — tied to Fragment VIEW lifecycle
Structured concurrency solves a fundamental problem with concurrent code: leaked coroutines. Before structured concurrency, you could launch work that outlived the object that started it — a ViewModel that was destroyed might still have a coroutine running, holding references to the old context and consuming resources. Structured concurrency makes it impossible to launch a coroutine without giving it a defined owner — a CoroutineScope — and the scope is responsible for the coroutine's full lifecycle.
Three rules govern structured concurrency. First, a parent coroutine waits for all of its children to complete before completing itself. Second, if the parent scope is cancelled, every child coroutine is cancelled immediately. Third, if a child fails with an uncaught exception, that exception propagates to the parent and cancels siblings (unless using a SupervisorJob). These rules create a tree of coroutines that behaves predictably — no coroutine can silently escape its owner.
GlobalScope is the anti-pattern: it has no owner, so coroutines launched there live until the process dies. In Android, that means a ViewModel coroutine that survives rotation and reuses of that screen, holding stale references. Always use viewModelScope for ViewModel work — it's cancelled automatically when ViewModel.onCleared() is called, which happens when the user permanently leaves that screen. For UI-driven coroutines in Fragments, use viewLifecycleOwner.lifecycleScope, not lifecycleScope — the Fragment can exist without a view when on the back stack, so the Fragment's own lifecycle outlives its view.
GlobalScope is almost always wrong in Android. Always use viewModelScope, lifecycleScope, or a custom scope with a Job. The rule: "coroutines should be cancelled when their owner is done." GlobalScope has no owner.
4. What is the difference between launch and async?
launch fires and forgets — it returns a Job. async starts a concurrent operation and returns a Deferred — a future value you can await.
// launch — fire and forget, returns Job val job = viewModelScope.launch { sendAnalyticsEvent() // don't need the result saveToDatabase() // side effect only } job.cancel() // can cancel // async — returns Deferred (future value) val deferred: Deferred<User> = viewModelScope.async { api.fetchUser("123") // returns User } val user = deferred.await() // suspends until result ready // KEY use case for async: PARALLEL execution viewModelScope.launch { // Sequential — takes 2 seconds total val user = fetchUser() // 1 second val profile = fetchProfile() // 1 second // Parallel — takes 1 second total val userDef = async { fetchUser() } // starts immediately val profileDef = async { fetchProfile() } // starts immediately val result = combine(userDef.await (), profileDef.await ()) } // async error handling — exception thrown at await() val result = try { async { riskyOp() }.await () } catch (e: Exception) { null } // awaitAll — await multiple Deferreds at once val results = awaitAll(userDef, profileDef, settingsDef)
launch and async are both coroutine builders, but they differ in intent. launch is fire-and-forget — you start work for its side effects (saving to a database, sending an analytics event) and don't need the result. It returns a Job you can cancel or join, but not read a value from. async is for when you need the result of the concurrent work. It returns a Deferred<T> — a future value. You call .await() to suspend until the result is ready.
The most important use case for async is parallel execution. If you have two independent API calls that each take one second, calling them sequentially takes two seconds. Starting both with async then calling .await() on each means they run concurrently — total time is one second (or just over, limited by the slower call). This is a concrete, measurable performance improvement that comes up constantly in real apps loading dashboards, user profiles, or feed data.
A common mistake is calling await() immediately after async — that's identical to sequential execution and defeats the purpose. The correct pattern is to start all the async blocks first, collect all the Deferred references, then await() them afterwards. awaitAll() is a convenience for waiting on a list of Deferreds simultaneously. Also note that exceptions thrown inside an async block are not thrown until await() is called — so always wrap await() in a try-catch if the operation can fail.
The classic interview question: "How do you make two API calls in parallel?" Answer: val a = async { fetchA() }; val b = async { fetchB() }; combine(a.await(), b.await()). This cuts total time from sum to max of both calls.
5. What is coroutine cancellation? How does it work and how do you make code cancellation-safe?
Coroutine cancellation is cooperative — the coroutine must check for cancellation. Suspend functions do this automatically; CPU-heavy loops must check manually.
// Cancellation is cooperative — not forced like Thread.interrupt() val job = viewModelScope.launch { // suspend functions check cancellation automatically delay(1000) // throws CancellationException if cancelled withContext(Dispatchers.IO) { api.fetch() } // same } job.cancel() // sets cancellation flag // CPU-heavy loop — must check manually suspend fun heavyComputation() { for (i in 0..1_000_000) { ensureActive() // ✅ throws if cancelled // or: if (!isActive) return compute(i) } } // withContext is NOT cancellable by default // yield() lets other coroutines run + checks cancellation suspend fun yieldExample() { repeat(1000) { i -> yield() // suspend point — cancel check + cooperative process(i) } } // Cleanup with finally — always runs even on cancellation val job2 = viewModelScope.launch { try { while (true) { doWork() } } finally { cleanup() // runs on cancellation too withContext(NonCancellable) { db.saveState() // NonCancellable needed to suspend in finally } } }
Coroutine cancellation is cooperative, not forceful. When you call job.cancel(), you set a cancellation flag — but the coroutine only acts on it when it reaches a suspension point. Every built-in suspend function — delay(), withContext(), await(), Room queries, Retrofit calls — checks this flag automatically and throws CancellationException when cancelled. For the vast majority of code, this means cancellation just works without any extra effort.
The gap is CPU-bound loops. If a coroutine runs a tight computation — iterating over a million items, encoding a large bitmap — with no suspension points, calling cancel() sets the flag but the coroutine never checks it and keeps running indefinitely despite being "cancelled". The fix is to periodically call ensureActive() (throws if cancelled) or isActive (returns false if cancelled) inside the loop. yield() does both — it's a suspension point that also gives other coroutines a chance to run, making it useful in tight loops on shared thread pools.
Cleanup on cancellation works through finally blocks — they always execute, even when a coroutine is cancelled. However, if your cleanup itself needs to suspend (for example, flushing a database or closing a network connection), you must wrap that suspension inside withContext(NonCancellable). Without this, the finally block itself gets cancelled before it finishes, silently skipping your cleanup. A critical rule to commit to memory: never swallow CancellationException in a generic catch(e: Exception) block — always re-throw it, or only catch specific exception types like IOException.
Forgetting ensureActive() in CPU loops is a real bug. If a coroutine runs a tight computation loop without suspension points, job.cancel() sets the flag but the coroutine NEVER checks it — it runs forever despite being "cancelled."
6. What is the difference between Job and SupervisorJob? When do you use each?
Job propagates failure to siblings — one child failing cancels all. SupervisorJob isolates failures — each child is independent. Android's viewModelScope uses SupervisorJob.
// Job — failure cascades to siblings val scope = CoroutineScope(Job()) scope.launch { launch { throw IOException("Network failed") } // fails launch { doImportantWork() } // CANCELLED by sibling failure! } // Both children cancelled when first throws // SupervisorJob — failure is isolated val supervisorScope = CoroutineScope(SupervisorJob()) supervisorScope.launch { launch { throw IOException("Network failed") } // fails launch { doImportantWork() } // continues! ✅ } // viewModelScope uses SupervisorJob internally // So one ViewModel coroutine failing doesn't cancel others // supervisorScope {} — function that creates supervisor scope suspend fun loadDashboard() = supervisorScope { val news = async { fetchNews() } // might fail val weather = async { fetchWeather() } // independent val stocks = async { fetchStocks() } // independent // If news fails, weather and stocks still complete Dashboard( news = runCatching { news.await () }.getOrNull (), weather = runCatching { weather.await () }.getOrNull (), stocks = runCatching { stocks.await () }.getOrNull () ) }
The difference between Job and SupervisorJob comes down to failure propagation. With a regular Job, the structured concurrency failure rules apply in full: if one child coroutine throws an unhandled exception, the parent is notified, the exception propagates upward, and all sibling coroutines are cancelled. This is the right behaviour when your child coroutines are tightly coupled — if one step in a multi-step transaction fails, the whole transaction should be rolled back.
SupervisorJob breaks this chain. A child failure is isolated — the supervisor absorbs it without cancelling siblings or propagating to the parent. You still need to handle the exception in the failing child (via try-catch or CoroutineExceptionHandler), but other children are unaffected. This is why Android's viewModelScope uses SupervisorJob internally: if one ViewModel coroutine fails (say, an analytics call), it shouldn't cancel all the other data-loading coroutines running in that scope.
In practice, the most useful form is the supervisorScope { } suspend function, which creates a temporary supervisor scope inside a coroutine. The classic pattern is the dashboard screen that loads multiple independent widgets — news, weather, stocks — in parallel. If the stocks API fails, you still want news and weather to display. Use supervisorScope with individual runCatching { } around each async block, so each piece of data either loads successfully or returns null/fallback, without one failure derailing the entire screen.
The dashboard loading pattern is a perfect SupervisorJob example. News, weather, stocks are independent — if stocks API fails, you still want to show news and weather. supervisorScope with runCatching per widget is the production-quality answer.
7. What is exception handling in coroutines? How do CoroutineExceptionHandler and try-catch differ?
Coroutine exception handling is nuanced — try-catch works inside coroutines, but CoroutineExceptionHandler is a last-resort handler for unhandled exceptions in launch.
// try-catch inside coroutine — handles exception locally viewModelScope.launch { try { val data = api.fetchData() _state.value = UiState.Success (data) } catch (e: IOException) { _state.value = UiState.Error (e.message !!) } } // runCatching — cleaner functional try-catch viewModelScope.launch { runCatching { api.fetchData() } .onSuccess { _state.value = UiState.Success (it) } .onFailure { _state.value = UiState.Error (it.message !!) } } // CoroutineExceptionHandler — last resort for launch{} // Does NOT work with async{} — exception is stored in Deferred val handler = CoroutineExceptionHandler { _, throwable -> logCrash(throwable) // log, don't crash } viewModelScope.launch(handler) { api.fetchData() // if this throws, handler catches it } // ⚠️ CancellationException is SPECIAL // Never swallow CancellationException — it breaks cancellation try { delay(1000) } catch (e: Exception) { if (e is CancellationException) throw e // ✅ re-throw! handleError(e) }
Exception handling in coroutines has a few non-obvious rules that cause real production bugs. The straightforward part: try-catch works exactly as you'd expect inside a coroutine. Exceptions thrown by suspend functions are caught normally, and you can respond with UI state updates. runCatching { } is a functional alternative that wraps the result in a Result type, letting you use .onSuccess { } and .onFailure { } for cleaner single-operation error handling.
CoroutineExceptionHandler is a last-resort handler for exceptions that escape launch { } blocks. It doesn't work with async { } — exceptions from async are stored inside the Deferred and only thrown when you call .await(). This asymmetry matters: an unhandled exception in launch with no handler will crash your app; the same exception in async is silently swallowed until await() is called. So for async, always wrap await() in a try-catch.
The sneakiest rule is CancellationException. It's a subclass of Exception, so a bare catch(e: Exception) { handleError(e) } will catch it and swallow it — which breaks the entire cancellation chain for that coroutine and all its children. The coroutine thinks it handled a normal error, but it just silently suppressed a cancellation signal. Always re-throw CancellationException explicitly, or write more specific catch clauses like catch(e: IOException). This is one of the most common coroutine bugs in production Android code.
CancellationException is the sneakiest coroutine trap. catch(e: Exception) { handleError(e) } will catch CancellationException and break the cancellation chain. Always re-throw CancellationException or use catch(e: IOException) to be specific.
8. What is Kotlin Flow? How is it different from LiveData and RxJava?
Flow is Kotlin's reactive stream — a cold, sequential sequence of values emitted asynchronously. It's Kotlin-native, coroutine-integrated, and lighter than RxJava.
// Flow — cold, sequential, coroutine-native fun getNumbers(): Flow<Int> = flow { for (i in 1..5) { delay(100) emit(i) // emit values over time } } // Collection — terminal operator starts the flow viewModelScope.launch { getNumbers().collect { value -> println(value) } } // COLD: new execution for each collect() // Comparison: // LiveData: Android-only, no operators, lifecycle-aware // RxJava: JVM, complex API, heavy dependency // Flow: Kotlin-native, rich operators, coroutine-integrated // Flow advantages over LiveData: // ✅ Type-safe null handling // ✅ Rich transformation operators (map, filter, zip, flatMap...) // ✅ Testing with Turbine library // ✅ Works on any platform (KMP) // ✅ Backpressure handling built-in // LiveData advantage: // ✅ Lifecycle-aware out of the box (no collectAsStateWithLifecycle needed) // Simple flow builders flowOf(1, 2, 3) // fixed values listOf(1, 2, 3).asFlow() // from collection channelFlow { send(1) } // from channel
Flow is Kotlin's coroutine-native reactive stream. The key characteristic that distinguishes it from other approaches is that it's cold: the code inside a flow { } builder doesn't run until a collector subscribes. Each collect() call triggers a fresh, independent execution — so two collectors of the same flow each get their own network call or database query. This is the right default for request-response patterns, but it's why you use stateIn() to convert to a hot flow when you want shared execution across multiple collectors.
Compared to LiveData, Flow has a richer operator set, proper null handling, and isn't tied to the Android framework — it works in plain Kotlin modules and Kotlin Multiplatform. LiveData's only meaningful advantage is built-in lifecycle awareness, but that gap is closed in Compose with collectAsStateWithLifecycle() and in Views with repeatOnLifecycle(). New code should default to Flow. Compared to RxJava, Flow is simpler — far fewer operator variants, direct coroutine integration, and no need to manage subscriptions with CompositeDisposable. RxJava still exists in older codebases, but Flow has replaced it for new Android development.
Backpressure — what happens when a producer emits faster than the consumer can process — is handled elegantly by default. Because emit() is a suspend function, it naturally suspends when the downstream collector is busy, creating automatic backpressure without any configuration. You opt into different strategies — buffer(), conflate(), collectLatest — when the default sequential behaviour isn't what you want. This is simpler than RxJava's explicit BackpressureStrategy enum.
Cold vs Hot is the key distinction. Flow is cold — each collect() triggers fresh execution. StateFlow/SharedFlow are hot — they emit regardless of collectors. This answers "why can't I collect a Flow twice and get the same values?"
9. What is the difference between StateFlow and SharedFlow? When do you use each?
Both are hot flows — they emit regardless of collectors. StateFlow is state (current value, replay=1). SharedFlow is events (configurable replay, no value requirement).
// StateFlow — always has a value, replays last value to new collectors class CounterViewModel : ViewModel() { private val _count = MutableStateFlow(0) val count: StateFlow<Int> = _count.asStateFlow() fun increment() { _count.value ++ } } // New subscriber gets CURRENT value immediately // Deduplicates: emitting same value twice = only one emission // SharedFlow — configurable replay, for events private val _events = MutableSharedFlow<UiEvent>( replay = 0, // no replay — event not re-sent to late collectors extraBufferCapacity = 64 // buffer events if no collector ) val events = _events.asSharedFlow() // Emit from background thread safely viewModelScope.launch { _events.emit (UiEvent.Navigate("/home")) } // Key differences: // StateFlow: replay=1, requires initial value, deduplicates // SharedFlow: replay=0 by default, no initial value, no dedup // When to use: // StateFlow → UI state (user profile, loading, error) // SharedFlow → one-time events (navigation, snackbar, toast) // Channel → one-time events, single consumer guaranteed // stateIn() — convert Flow to StateFlow val uiState = repo.getUser() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)
Both StateFlow and SharedFlow are hot flows — they emit values regardless of whether there are active collectors, and multiple collectors share the same stream. But they serve different purposes and have critically different behaviours. StateFlow always holds a current value: you must provide an initial value, and any new collector immediately receives the most recent emission. It also deduplicates — emitting the same value twice produces only one downstream notification. This makes it perfect for UI state, where what matters is always the current state, not the history of how you got there.
SharedFlow has configurable replay (default 0) and no initial value requirement. With replay=0, a late collector misses emissions that happened before it subscribed — there's no cached value to deliver. This makes it correct for one-time events: navigation commands, snackbar messages, dialog triggers. If a user navigates away and the screen is recreated, you don't want it to navigate again just because a new collector subscribed. The deduplication absence also matters — navigating to the same route twice should fire two navigation events.
stateIn() is the bridge that converts a cold Flow (like a Room database query) into a hot StateFlow cached in a CoroutineScope. Without it, two UI components collecting the same repository flow would trigger two separate database queries. With stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initialValue), the upstream runs once, the result is shared, and the 5-second grace window means the upstream stays active during configuration changes (rotation takes ~200ms) but stops cleanly when the user genuinely leaves the screen.
StateFlow deduplicates — emitting the same value twice only triggers one downstream update. This is correct for state (current count=5 IS the state) but wrong for events (navigating to the same screen twice should fire twice). That's why events need SharedFlow or Channel.
10. Explain the key Flow operators — map, filter, transform, zip, combine, flatMapLatest.
Flow operators are the core of reactive programming. Each transforms the stream differently — mastering them is essential for clean, efficient reactive Android code.
// map — transform each value flowOf(1, 2, 3).map { it * 2 } // 2, 4, 6 userFlow.map { it.name } // User → String // filter — keep only matching values flowOf(1, 2, 3, 4).filter { it % 2 == 0 } // 2, 4 // transform — emit multiple values per input flowOf(1, 2).transform { value -> emit("before $value") emit("after $value") } // "before 1", "after 1", "before 2", "after 2" // zip — combine two flows pair by pair val names = flowOf("Alice", "Bob") val scores = flowOf(100, 200) names.zip(scores) { name, score -> "$name: $score" } // "Alice: 100", "Bob: 200" — waits for both, 1:1 // combine — emit whenever EITHER source emits val query = MutableStateFlow("") val filters = MutableStateFlow(emptyList<String>()) query.combine(filters) { q, f -> search(q, f) } // re-runs search whenever query OR filters change // flatMapLatest — switch to new flow, cancel previous queryFlow .debounce(300) .flatMapLatest { query -> searchRepo(query) // cancels previous search on new query } // flatMapConcat — sequential (wait for previous to complete) // flatMapMerge — parallel (all run simultaneously) // flatMapLatest — cancel previous (for search/reactive queries)
Flow operators are the core vocabulary of reactive programming. The basic operators — map, filter, take, drop — work identically to their collection counterparts but execute lazily as values flow through. map transforms each emitted value; filter drops values that don't match a predicate. transform is a generalisation of both: it can emit zero, one, or multiple values for each input, giving you full control over the output stream when map's one-to-one restriction isn't sufficient.
zip and combine merge two flows but behave very differently. zip pairs emissions one-to-one — it waits for both flows to emit, then combines them, then waits for the next pair. It's ordered and produces exactly as many outputs as the shorter flow. combine emits whenever either source emits, always using the latest value from each. This makes it the right operator for reactive UI state where multiple independent inputs drive one output — for example, combining a search query flow with a filter selection flow to re-run search whenever either changes.
flatMapLatest is one of the most practically important operators in Android development. When a new value is emitted, it cancels whatever work was started by the previous emission and starts fresh with the new one. Paired with debounce() on a search query flow, it's how you build search-as-you-type: the user types "an", then "and" — the "an" search is cancelled and only the "and" search completes. flatMapConcat (sequential) and flatMapMerge (parallel) are the alternatives when cancelling previous work isn't the right behaviour.
flatMapLatest is the search operator. combine is the multi-source state operator. The question "user can filter AND sort AND search simultaneously" is answered with combine(queryFlow, filterFlow, sortFlow) { q, f, s -> search(q, f, s) }.
11. What is withContext and how is it different from launch/async?
withContext switches the coroutine to a different dispatcher and returns a result — it's a scope switch, not a new coroutine. This makes it efficient for switching context mid-operation.
// withContext — switch dispatcher, return result // Does NOT start a new coroutine — same coroutine, different thread suspend fun fetchUser(id: String): User { return withContext(Dispatchers.IO) { // switches to IO api.getUser(id) // runs on IO thread } // resumes on original dispatcher } // Full flow — Main → IO → Main viewModelScope.launch { // starts on Main _state.value = UiState.Loading val user = withContext(Dispatchers.IO) { // switches to IO api.getUser("123") } // back to Main _state.value = UiState.Success (user) // on Main } // withContext vs launch: // withContext: same coroutine, different context, returns result, sequential // launch: new coroutine, fire-and-forget, parallel // withContext vs async: // withContext: sequential — suspends until done // async: parallel — use for concurrent operations // Nesting withContext — fine, no extra cost suspend fun process(): String { val raw = withContext(Dispatchers.IO) { fetchRaw() } return withContext(Dispatchers.Default) { parseAndTransform(raw) } }
withContext is the standard mechanism for switching threads within a coroutine without creating a new one. When you call withContext(Dispatchers.IO) { }, the current coroutine suspends, the block runs on the IO thread pool, and when the block completes, the coroutine resumes on the original dispatcher with the result. The key distinction from async is that withContext is sequential — the outer coroutine waits for the block to finish before continuing. This makes it the right tool when you need a value back from the context switch.
Because withContext doesn't create a new coroutine, it's cheaper than launch { }.join() or async { }.await() for single-result context switches. The standard Android architecture pattern uses this at the repository layer: every function that touches the network or database wraps its work in withContext(Dispatchers.IO). The ViewModel calls these functions from viewModelScope.launch { } (which runs on Dispatchers.Main by default) and never needs to know which thread the actual work runs on. The threading is encapsulated in the repository.
You can nest withContext calls freely — switching from IO to Default, for example, to parse data after fetching it — and each switch is efficient. A common mistake is calling withContext(Dispatchers.IO) from inside a function already running on IO — this is a no-op, but it's not harmful. More importantly, never forget to wrap blocking legacy calls (SharedPreferences, old file APIs, third-party blocking SDKs) in withContext(IO) — only Room and Retrofit handle the dispatcher switch automatically for their own suspend functions.
Repository pattern rule: wrap ALL blocking calls in withContext(Dispatchers.IO) inside the repository. The ViewModel never needs to know about dispatchers — it just calls suspend functions. This is the clean architecture approach to threading.
12. What is cold vs hot flow? How do stateIn and shareIn work?
Cold flows execute fresh for each collector. Hot flows share a single execution with all collectors. stateIn and shareIn convert cold flows to hot StateFlow/SharedFlow.
// COLD — new execution per collector val coldFlow = flow { println("Starting network call...") emit(api.fetchData()) } coldFlow.collect { } // "Starting network call..." coldFlow.collect { } // "Starting network call..." AGAIN! // HOT — single execution, shared with all collectors // StateFlow, SharedFlow, channelFlow // stateIn() — convert cold Flow to hot StateFlow class UserViewModel : ViewModel() { val user: StateFlow<User?> = repo.observeUser() // cold Flow from Room .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), // upstream active while subscribed initialValue = null ) // Room query runs ONCE, shared with all UI collectors } // SharingStarted options: // Eagerly — start immediately, never stop // Lazily — start on first subscriber, never stop // WhileSubscribed(5000) — start on first subscriber, stop 5s after last unsubscribes // WhileSubscribed(5000) — 5s grace period survives config change // shareIn() — convert to SharedFlow val locationFlow = gps.locationUpdates() .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
Cold and hot describe when a flow starts emitting and who receives those emissions. A cold flow starts fresh for every collector — the flow { } lambda re-executes from the beginning. Two collectors of a cold API flow trigger two separate network requests. This is correct for on-demand data fetching, but it's wasteful for shared state that multiple UI components need to observe simultaneously.
Hot flows run independently of collectors and share their emissions. StateFlow and SharedFlow are hot by nature. stateIn() is how you promote a cold flow to hot — you give it a scope, a sharing policy, and an initial value, and it starts the upstream flow once, caching the latest result as a StateFlow. Any number of collectors then observe that single shared stream. This is the production pattern for exposing Room database flows from a ViewModel: one database query drives multiple screen components.
The SharingStarted parameter controls when the upstream starts and stops. Eagerly starts immediately and never stops — wasteful if no UI is attached yet. Lazily starts on first subscriber and never stops — slightly better, but still wastes resources when the user leaves. WhileSubscribed(5000) is the production choice: it starts on first subscriber and stops 5 seconds after the last subscriber disappears. The 5-second grace period is long enough to survive a configuration change (rotation takes ~200ms) but short enough that the upstream genuinely stops when the user navigates away. This is the recommended pattern from Google's architecture guidelines.
WhileSubscribed(5000) is the production answer. 5 seconds survives rotation (Activity recreates in ~200ms). Without it (Lazily), the upstream never stops — wasting resources. Eagerly starts immediately — even before any UI is shown.
13. What are Kotlin Channels? Explain Channel types and how they differ from Flow.
Channels are hot communication primitives for sending values between coroutines — like a queue. Unlike Flow, Channels are consumed — each value is received by exactly one collector.
// Channel — producer/consumer queue val channel = Channel<Int>() // Producer coroutine viewModelScope.launch { for (i in 1..5) { channel.send(i) } // suspends if full channel.close() } // Consumer coroutine viewModelScope.launch { for (value in channel) { println(value) } // suspends if empty } // Channel types (capacity): // RENDEZVOUS (default, 0) — send suspends until receive // UNLIMITED — send never suspends (unbounded buffer) // BUFFERED (default 64) — send suspends when buffer full // CONFLATED — only latest value kept, never suspends val rendezvous = Channel<Int>(Channel.RENDEZVOUS) val buffered = Channel<Int>(Channel.BUFFERED) val conflated = Channel<Int>(Channel.CONFLATED) // Channel as one-time event bus private val _events = Channel<UiEvent>(Channel.BUFFERED) val events = _events.receiveAsFlow() // expose as Flow // Channel vs Flow: // Channel: HOT, each value consumed ONCE, one consumer // Flow: COLD, each collector gets all values independently
Channels are the coroutine primitive for communicating values between producers and consumers — think of them as a coroutine-safe queue. Unlike Flow, which is cold and replays for each collector, a Channel is hot: each value sent through it is received by exactly one consumer, and once consumed it's gone. This point-to-point delivery semantic makes Channels the right tool when each piece of work should be processed once, not broadcast.
The channel capacity determines how the producer and consumer synchronise. RENDEZVOUS (capacity 0) means the sender suspends until a receiver is ready to take the value — direct handoff, no buffering. BUFFERED (default 64 slots) lets the sender run ahead of the consumer, suspending only when the buffer fills. CONFLATED keeps only the most recent value — the producer never suspends, but if the consumer is slow, intermediate values are silently dropped. UNLIMITED has no bound, but risks unbounded memory growth if the producer consistently outpaces the consumer.
In Android, Channels have a specific recommended use case: one-time UI events like navigation commands, snackbar messages, or dialog triggers, where you want guaranteed delivery to exactly one consumer and no replay on rotation. The pattern is to declare a Channel.BUFFERED privately in the ViewModel and expose it as channel.receiveAsFlow(). The buffer ensures events aren't dropped if the UI momentarily has no collector, while receiveAsFlow() provides the familiar Flow collection API. This solves the problem that SharedFlow(replay=0) has: events emitted when no collector is active are lost, whereas a buffered Channel holds them.
Use Channel for one-time events (navigation, snackbar) that should go to exactly ONE consumer and not be replayed. Use SharedFlow(replay=0) when you might have multiple collectors. Channel.BUFFERED + receiveAsFlow() is the standard one-time event pattern.
14. What are coroutine contexts and how do they compose?
A CoroutineContext is a set of elements that define a coroutine's behavior — dispatcher, job, name, and exception handler. Elements combine with the + operator.
// CoroutineContext elements val context = Dispatchers.IO + // which thread SupervisorJob() + // job hierarchy CoroutineName("DataLoader") + // debug name CoroutineExceptionHandler { _, e -> logError(e) } val scope = CoroutineScope(context) // + operator — later element overrides earlier val combined = Dispatchers.IO + Dispatchers.Main // Only one Dispatcher allowed — Main wins (last one) // Child coroutines inherit parent context viewModelScope.launch(Dispatchers.IO) { // overrides default Main launch { // inherits IO + parent Job println(coroutineContext[CoroutineDispatcher]) // IO } } // coroutineContext — access current context inside coroutine suspend fun example() { println(coroutineContext[Job]) println(coroutineContext[CoroutineName]) println(coroutineContext.isActive ) } // Android built-in scopes and their contexts // viewModelScope: SupervisorJob + Dispatchers.Main.immediate // lifecycleScope: SupervisorJob + Dispatchers.Main.immediate
A CoroutineContext is an immutable, map-like collection of elements that together define everything about how a coroutine behaves. The four main elements are: the Dispatcher (which thread pool), the Job (lifecycle and cancellation), the CoroutineName (debug label visible in stack traces and debugger), and the CoroutineExceptionHandler (last-resort error handler). You compose them with the + operator — if you add two elements of the same type, the right-hand one wins.
When you launch a child coroutine, it inherits its parent's context but replaces the Job element with a new child Job that is linked to the parent. This link is the actual mechanism behind structured concurrency — the child Job knows its parent, so parent cancellation propagates down, and child completion notifies the parent. If you override the dispatcher in launch(Dispatchers.IO), only the Dispatcher element is replaced; the SupervisorJob, name, and handler are all inherited from the parent scope. This is why cancellation still works correctly after switching dispatchers.
Inside any suspend function, you can access the current context through the coroutineContext property — it's a suspend function receiver property available without any import. This lets you check coroutineContext.isActive for cooperative cancellation, or read coroutineContext[CoroutineName] for logging. Understanding the context composition model explains subtle bugs — for instance, why passing a new Job() to launch breaks the parent-child link and causes structured concurrency to stop working for that coroutine.
viewModelScope.launch(Dispatchers.IO) doesn't replace the entire context — it just overrides the Dispatcher element. The SupervisorJob and other elements are inherited. Understanding this explains why cancellation still works correctly even when switching dispatchers.
15. What is the Flow lifecycle? Explain onStart, onCompletion, and onEach.
Flow lifecycle operators let you hook into collection events — emission start, each value, completion, and errors. They're used for logging, loading indicators, and cleanup.
// onStart — runs before first emission fun getUsers(): Flow<List<User>> = repo.getAllUsers() .onStart { emit(emptyList()) // emit loading placeholder println("Started collecting") } // onEach — side effect per emission, passes value through repo.getUsers() .onEach { users -> println("Got ${users.size} users") } .collect { updateUi(it) } // onCompletion — runs on completion (normal, error, or cancel) repo.getUsers() .onCompletion { cause -> if (cause != null) logError(cause) else println("Completed successfully") hideLoadingIndicator() // always runs } // catch — handle errors mid-stream repo.getUsers() .catch { e -> emit(emptyList()) // emit fallback on error logError(e) } // Loading indicator pattern repo.getUsers() .onStart { _loading.value = true } .onCompletion { _loading.value = false } .catch { _error.value = it.message } .collect { _users.value = it }
Flow lifecycle operators let you hook into key moments in a stream's execution without interrupting the flow of values. onStart runs once before the first emission — it's the right place to show a loading indicator, emit an initial placeholder value, or log that collection has begun. It receives the FlowCollector receiver, so you can call emit() to inject values into the stream before the real upstream data arrives.
onEach fires after every emission and passes the value through unchanged, making it ideal for side effects — analytics events, debug logging, updating a secondary state holder — without modifying the main data stream. onCompletion is Flow's equivalent of a finally block: it always executes whether the flow completed normally, was cancelled, or threw an exception. The lambda receives the cancellation cause (null on normal completion), letting you both check the outcome and unconditionally clean up — the most common use is hiding a loading spinner.
catch intercepts exceptions thrown by operators upstream of it in the chain. You can recover by emitting a fallback value, log the error, or re-throw a different exception. A critical subtlety: catch only catches upstream errors — it does not catch exceptions thrown inside the collect { } terminal operator. If your collector throws, the exception propagates to the coroutine's exception handler, not through the flow chain. The standard production pattern combines all four: .onStart { showLoading() }.catch { emit(fallback) }.onCompletion { hideLoading() }.collect { updateUi(it) }.
The loading indicator pattern using onStart/onCompletion is cleaner than managing loading state manually. The completion callback always runs — on success, on error, and on cancellation — making it perfect for hiding spinners.
16. What is backpressure in Flow? How does Kotlin Flow handle it?
Backpressure occurs when a producer emits faster than the consumer processes. Kotlin Flow handles it through suspension — the emitter naturally slows to match the consumer.
// Backpressure — producer faster than consumer val fastFlow = flow { for (i in 1..1000) { emit(i) // emits as fast as possible } } // Default — suspension handles backpressure automatically fastFlow.collect { value -> delay(100) // slow consumer process(value) } // emit() SUSPENDS when collector is slow — natural backpressure // buffer() — process producer and consumer concurrently fastFlow .buffer(64) // buffer up to 64 values .collect { process(it) } // Producer fills buffer without waiting for consumer // conflate() — drop intermediate values, keep only latest fastFlow .conflate() // only latest value when collector is ready .collect { renderFrame(it) } // Perfect for UI frame rendering — old frames skipped // collectLatest — cancel slow processing for new values fastFlow.collectLatest { value -> delay(100) // if new value arrives, this is cancelled process(value) // only latest value fully processed } // Use case guide: // buffer() — producer needs to run ahead (disk → network) // conflate() — only latest matters (sensor data, price ticks) // collectLatest — latest request cancels in-progress work
Backpressure is what happens when a producer generates values faster than the consumer can process them. In RxJava, this required explicit strategy choices and could cause MissingBackpressureException if misconfigured. Kotlin Flow handles it elegantly by default: emit() is a suspend function. When the downstream collector is still processing a value, emit() suspends — the producer waits. No overflow, no dropped values, no configuration required. This makes Flow's default behaviour safe by design.
The default sequential behaviour isn't always what you want, and three operators let you trade off. buffer(n) runs the producer and consumer concurrently in separate coroutines with a channel of capacity n between them — the producer can run ahead by up to n values while the consumer processes. This is useful when the producer and consumer are both slow in different ways and you want them to pipeline. conflate() takes a more aggressive approach: only the latest value is kept. If the producer emits 1, 2, 3 while the consumer is processing 1, the consumer skips 2 and only processes 3. Perfect for sensor data or price ticks where only the freshest value matters.
collectLatest is the most powerful option for UI scenarios. When a new emission arrives while the collector is still processing the previous one, the collector's coroutine is cancelled and restarted with the new value. Only the most recent emission ever runs to full completion. This is how you implement a search field that cancels an in-flight search when the user keeps typing — pair it with debounce(300) to avoid cancelling too aggressively on every keystroke.
Flow's default backpressure is elegant: emit() is a suspend function, so it naturally waits. This contrasts with RxJava which needed explicit backpressure strategies. For UI rendering, conflate() is ideal — you only want to render the most recent frame.
17. How do you test coroutines and Flow? Explain TestCoroutineDispatcher and Turbine.
Coroutine testing requires controlling time and dispatchers. The kotlinx-coroutines-test library provides TestDispatcher and runTest for fast, deterministic tests.
// testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test") // testImplementation("app.cash.turbine:turbine") // runTest — replaces runBlocking in tests // Controls virtual time — delay(1000) completes instantly class UserViewModelTest { @Test fun loadsUser() = runTest { val vm = UserViewModel(FakeRepository()) vm.loadUser("123") assertEquals(UiState.Loading , vm.state.value ) advanceUntilIdle() // run all pending coroutines assertTrue(vm.state.value is UiState.Success ) } } // Inject dispatcher for testability class UserViewModel( private val repo: UserRepository, private val dispatcher: CoroutineDispatcher = Dispatchers.IO // injectable! ) : ViewModel() { } // In tests — use TestDispatcher val testDispatcher = StandardTestDispatcher() val vm = UserViewModel(repo, testDispatcher) // Turbine — Flow testing library @Test fun flowEmitsCorrectly() = runTest { repo.getUser().test { assertEquals(UiState.Loading, awaitItem()) assertEquals(UiState.Success (user), awaitItem()) awaitComplete() } } // advanceTimeBy — test time-based flows @Test fun debounceWorks() = runTest { query("a") advanceTimeBy(100) // under debounce threshold query("ab") advanceTimeBy(400) // past debounce — triggers search assertEquals("ab", lastQuery) }
Testing coroutines requires controlling two things: which threads they run on and how time is perceived. The kotlinx-coroutines-test library solves both. runTest { } replaces runBlocking for test coroutines and installs a virtual clock — delay(1000) inside runTest completes in nanoseconds of real time, not a full second. This makes tests for time-sensitive logic (debounce, retry with backoff, polling) run at full speed without actually waiting.
Two dispatcher types are available for tests. StandardTestDispatcher gives you precise control — coroutines are queued but don't run until you explicitly advance the scheduler with advanceUntilIdle(), advanceTimeBy(ms), or runCurrent(). This lets you assert state at specific points in time. UnconfinedTestDispatcher runs coroutines eagerly on the current thread, which is simpler but gives less control over execution order. The most important testing practice is injecting your CoroutineDispatcher as a constructor parameter with a default of Dispatchers.IO — in tests, you pass StandardTestDispatcher(testScheduler) and gain full control over execution.
For testing Flow emissions, the Turbine library (app.cash.turbine) is the standard choice. Instead of launching a coroutine and fighting with timing, you call flow.test { } and use awaitItem(), awaitComplete(), and awaitError() to assert each emission in order. This makes Flow tests readable and deterministic. For ViewModel testing with StateFlow, combine runTest with Turbine, use StandardTestDispatcher for the ViewModel's scope, and remember to set Dispatchers.setMain(testDispatcher) in your test setup so viewModelScope is also controlled.
Always inject Dispatchers — never hardcode them in production code. Pass CoroutineDispatcher as a constructor parameter with a default value. In tests, pass TestDispatcher. This is the single most important coroutine testing practice.
18. What is the difference between suspend fun and regular fun? How does the compiler transform suspend functions?
The suspend modifier triggers a compile-time transformation — the compiler adds a Continuation parameter and generates a state machine. No JVM magic involved.
// What you write: suspend fun fetchUser(id: String): User { val raw = withContext(Dispatchers.IO) { api.get(id) } return parseUser(raw) } // What compiler generates (conceptually — CPS transform): fun fetchUser(id: String, cont: Continuation<User>): Any { // State machine with labels when (cont.label) { 0 -> { cont.label = 1 val result = withContext(Dispatchers.IO, { api.get(id) }, cont) if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED } 1 -> { val raw = cont.result as RawData return parseUser(raw) } } } // Continuation = "rest of the computation" // Each suspension point = one state in the state machine // COROUTINE_SUSPENDED = coroutine paused, thread released // Why suspend is "contagious": // fetchUser() now takes a Continuation parameter // Its callers must also take a Continuation // → Only callable from suspend functions or coroutine builders // suspend fun can call regular fun — no restriction // regular fun CANNOT call suspend fun — no continuation to pass suspend fun ok() { regularFun() } // ✅ fun broken() { suspendFun() } // ❌ compile error
The suspend keyword triggers a compile-time transformation — there is no JVM or runtime magic. The Kotlin compiler applies Continuation Passing Style (CPS): every suspend fun gets an extra Continuation<T> parameter appended to its signature. A Continuation is essentially a callback that holds "what to do next" after the suspension resolves — your local variables, execution position, and the next step of the computation. At the JVM bytecode level, a suspend fun is an ordinary function returning Object.
The function body is transformed into a state machine — a when expression with one label per suspension point. The first time the function is called, it enters state 0, starts the first async operation, saves its local state into the Continuation, and returns the sentinel value COROUTINE_SUSPENDED. This signals the coroutine runtime to release the thread. When the operation completes, the Continuation's resumeWith(result) is called, which re-enters the state machine at state 1, restores local variables from the Continuation, and continues execution. This continues for each suspension point in the function.
This is why suspend is "contagious" — a suspend fun takes a Continuation parameter, so its callers must also provide a Continuation, meaning they must also be suspend functions or coroutine builders. Regular functions have no Continuation to pass, which is why the compiler gives you a compile error if you try to call a suspend function from a non-suspend context. Understanding this transformation demystifies coroutines: they're not a runtime feature, they're a sophisticated compile-time code generation pattern that transforms sequential-looking code into an efficient callback state machine.
When asked "how do coroutines work?", explain CPS: "The compiler transforms suspend functions into state machines with Continuation callbacks. No JVM magic — it's purely compile-time transformation. The Continuation holds the 'rest of the computation' after each suspension point."
19. What is the difference between collect, collectLatest, and launchIn for Flow collection?
Three different ways to collect a Flow — each with different behavior for slow collectors and coroutine scope management.
// collect — sequential, waits for each emission to process viewModelScope.launch { flow.collect { value -> delay(500) // process takes 500ms renderUi(value) // new emissions queue behind this } } // collectLatest — cancels processing when new value arrives viewModelScope.launch { searchQueryFlow.collectLatest { query -> delay(300) // if new query arrives, cancelled! val results = search(query) // only latest query runs to completion showResults(results) } } // launchIn — collect in background, returns Job // Uses onEach for side effects repo.observeUser() .onEach { user -> updateUi(user) } .launchIn(viewModelScope) // non-blocking, returns Job // Equivalent to: viewModelScope.launch { flow.collect { ... } } // Multiple flows with launchIn userFlow.onEach { handleUser(it) }.launchIn(viewModelScope) settingsFlow.onEach { applySettings(it) }.launchIn(viewModelScope) errorFlow.onEach { showError(it) }.launchIn(viewModelScope) // All three collected concurrently! // When to use which: // collect: sequential processing, order matters // collectLatest: only latest matters, cancel old (search, UI updates) // launchIn: fire-and-forget collection, multiple concurrent flows
These three collection approaches differ in how they handle slow processing and how they integrate with scope management. collect { } is the fundamental terminal operator — it suspends the current coroutine and processes each emission sequentially. If your collector takes 500ms per emission and the flow emits every 100ms, emissions queue up and are processed one by one in arrival order. This is correct when every value must be handled and order matters — processing a sequence of database write operations, for example.
collectLatest { } is designed for scenarios where only the most recent value matters. When a new emission arrives while the collector's block is still executing, that block is cancelled and restarted with the new value. Only the most recent emission ever runs to full completion. This is the right operator for search-as-you-type, where each new character typed should cancel the previous search and start fresh, or for rendering the latest UI state when intermediate states are no longer relevant by the time you're ready to process them.
launchIn(scope) is syntactic sugar for scope.launch { flow.collect { } }, but it reads more declaratively when chained with onEach. Its real advantage is that it doesn't block the calling scope — you can start multiple flow collections with chained launchIn calls and they all run concurrently. It returns a Job that you can cancel individually if needed. The pattern of flow.onEach { handleValue(it) }.launchIn(viewModelScope) is clean, readable, and the standard approach for multiple concurrent flow observations in a ViewModel.
collectLatest is the search-as-you-type operator. When the user types "an" and then immediately "and", the "an" search is cancelled and only "and" runs to completion. This is exactly what debounce + collectLatest or flatMapLatest achieve in practice.
20. What is mutex and how do you handle concurrent state updates safely?
When multiple coroutines access shared mutable state, race conditions occur. Kotlin provides Mutex — a coroutine-friendly lock — and atomic operations for thread-safe state.
// Problem — race condition var counter = 0 repeat(1000) { viewModelScope.launch(Dispatchers.Default) { counter++ // NOT thread-safe! Race condition! } } // Final value: anywhere from 1 to 1000 // Solution 1: Mutex — coroutine-friendly lock val mutex = Mutex() var counter = 0 repeat(1000) { viewModelScope.launch(Dispatchers.Default) { mutex.withLock { counter++ } // suspends, doesn't block thread } } // Final value: exactly 1000 ✅ // Solution 2: Atomic types val atomicCounter = AtomicInteger(0) atomicCounter.incrementAndGet() // always thread-safe // Solution 3: Confine to single thread (Actor pattern) // StateFlow always writes from same coroutine private val _state = MutableStateFlow(AppState()) viewModelScope.launch { // single coroutine owns state actionChannel.consumeEach { action -> _state.value = reduce(_state.value , action) } } // Mutex vs synchronized: // synchronized: BLOCKS the thread // Mutex.withLock: SUSPENDS the coroutine — thread is freed // Always prefer Mutex in coroutine code
When multiple coroutines share mutable state, race conditions occur. Because coroutines can run on multiple threads simultaneously — especially on Dispatchers.Default and Dispatchers.IO — a simple counter++ is not atomic. The read, increment, and write are three separate operations, and another thread can intervene between any of them. Running 1,000 coroutines that each increment a shared counter will produce a final value anywhere from 1 to 1,000 — non-deterministically.
Kotlin's Mutex solves this with a coroutine-aware lock. Unlike Java's synchronized block, which blocks the entire OS thread while waiting, Mutex.withLock { } suspends the coroutine — the thread is released to run other coroutines while this one waits for the lock. On a shared IO thread pool with 64 threads, using synchronized can cause thread starvation: threads blocked waiting for the lock can't do other useful work. Mutex avoids this entirely. For simple counters or single-value references, AtomicInteger and AtomicReference are even better — lock-free operations backed by CPU atomic instructions, faster than any locking approach.
The most architecturally clean approach for complex shared state is single-thread confinement: designate one coroutine as the sole owner of mutable state. A Channel acts as an inbox — all state modifications are sent as messages, and a single coroutine dequeues and applies them one at a time. This is the actor model, and it completely eliminates concurrent access to the state. In Android, the MutableStateFlow pattern in a ViewModel achieves something similar: all updates go through viewModelScope.launch { _state.value = ... } on the Main dispatcher, which is single-threaded.
The key insight: synchronized{} blocks the thread — terrible for coroutines on shared thread pools. Mutex.withLock{} suspends — the thread is freed to run other coroutines. On Dispatchers.IO with 64 threads, synchronized can cause serious thread starvation.
21. What are the main threading rules in Android and how do coroutines help?
Android has strict threading rules — UI can only be updated on the main thread, network/disk must not block the main thread. Coroutines make these rules easy to follow.
// Android threading rules: // 1. UI updates ONLY on main thread → CalledFromWrongThreadException // 2. Network NEVER on main thread → NetworkOnMainThreadException // 3. Heavy computation NOT on main thread → ANR (>5s) // Old way — complex and error-prone Thread { val data = api.fetchData() // background thread runOnUiThread { updateView(data) } // back to main thread }.start() // Coroutines way — clean and safe viewModelScope.launch { // Main thread val data = withContext(Dispatchers.IO) { api.fetchData() // IO thread } // back to Main updateState(data) // Main thread } // Room + Coroutines — automatic IO dispatch @Dao interface UserDao { @Query("SELECT * FROM users") suspend fun getAllUsers(): List<User> // Room runs on IO automatically @Query("SELECT * FROM users") fun observeUsers(): Flow<List<User>> // Flow runs on IO automatically } // Retrofit + coroutines — suspend automatically interface ApiService { @GET("/users") suspend fun getUsers(): List<User> // Retrofit handles IO switching } // Dispatchers.Main.immediate — avoid unnecessary posts // If already on Main, runs immediately; otherwise posts // viewModelScope uses Main.immediate internally
Android enforces strict threading rules at runtime. The main thread (UI thread) is the only thread allowed to modify Views — calling textView.text = "..." from a background thread throws CalledFromWrongThreadException immediately. Conversely, making a network call on the main thread throws NetworkOnMainThreadException, which Android added deliberately to prevent ANRs — a blocked main thread means the UI freezes, and after 5 seconds Android shows the "App Not Responding" dialog. Heavy computation on the main thread causes the same ANR without a crash, which is subtler and harder to debug.
Coroutines make following these rules natural. viewModelScope.launch { } starts on Dispatchers.Main by default, so you're safe to update _state.value before and after the withContext call. The withContext(Dispatchers.IO) { } block handles the thread switch, does the blocking work, and switches back — all transparently. The code reads sequentially even though execution spans multiple threads, which is what makes coroutines so much cleaner than the callback-based alternatives or manual runOnUiThread { } gymnastics.
Knowing which libraries handle IO dispatch automatically is important for senior-level discussions. Room's suspend fun DAO methods automatically run on a background executor, as do Retrofit's suspend service methods. You still need explicit withContext(Dispatchers.IO) for raw file I/O, legacy blocking libraries, SharedPreferences writes, and any third-party SDK that doesn't declare its threading behaviour. Dispatchers.Main.immediate is a subtlety worth knowing: it runs the block immediately if already on the main thread, avoiding an unnecessary post to the message queue — this is what viewModelScope uses internally to avoid a frame of latency when updating UI state.
Room and modern Retrofit handle IO dispatch automatically for suspend functions. You still need withContext(IO) for raw file I/O, legacy libraries, or SharedPreferences. Knowing which libraries handle it and which don't shows real-world experience.
22. What is the purpose of viewModelScope, lifecycleScope, and viewLifecycleOwner.lifecycleScope?
Android provides three built-in scopes tied to different lifecycle owners. Choosing the right one prevents memory leaks and cancelled-work bugs.
// viewModelScope — tied to ViewModel lifecycle class UserViewModel : ViewModel() { fun loadUser() { viewModelScope.launch { val user = repo.getUser() _state.value = user } // Cancelled when ViewModel.onCleared() is called // Survives configuration changes (rotation) } } // lifecycleScope — tied to Activity/Fragment lifecycle class MainActivity : AppCompatActivity() { override fun onCreate(...) { lifecycleScope.launch { startAnimation() // cancelled on Activity destroy } // Cancelled when Activity is destroyed // Cancelled on ROTATION (Activity recreates!) } } // viewLifecycleOwner.lifecycleScope — tied to Fragment VIEW class UserFragment : Fragment() { override fun onViewCreated(...) { // ✅ Correct for collecting UI state viewLifecycleOwner.lifecycleScope.launch { viewModel.state.collectAsStateWithLifecycle() } // ❌ Wrong — Fragment lifecycle outlives view lifecycleScope.launch { viewModel.state.collect { } } } } // repeatOnLifecycle — suspend until lifecycle state viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state.collect { updateUi(it) } // Stops when STOPPED, resumes when STARTED } }
Android provides three built-in coroutine scopes tied to different lifecycle owners, and choosing the wrong one is a common source of bugs — either memory leaks (coroutine outlives its owner) or crashes (coroutine accesses a destroyed view). viewModelScope is tied to the ViewModel's lifecycle — it's cancelled when ViewModel.onCleared() is called, which happens when the user permanently navigates away (not on rotation). This makes it the right scope for data fetching and business logic that should survive configuration changes. The coroutine keeps running through rotation, and the new Activity observes the already-complete (or still-in-progress) result.
lifecycleScope is tied to the Activity or Fragment's lifecycle — cancelled when the Activity or Fragment is destroyed. For Activities, this works well for UI-level coroutines like animations or one-shot operations triggered by user interaction. However, for Fragments it's a trap: the Fragment's lifecycle is not the same as its view's lifecycle. A Fragment can be pushed onto the back stack and have its view destroyed while the Fragment itself remains alive. If you collect a Flow using the Fragment's lifecycleScope, you'll collect into a null view hierarchy, causing crashes or ghost updates.
The correct scope for Fragment UI work is viewLifecycleOwner.lifecycleScope — it's cancelled when the Fragment's view is destroyed, matching the actual lifecycle of the UI. Even better is combining it with repeatOnLifecycle(Lifecycle.State.STARTED): collection automatically pauses when the app goes to the background (STOPPED state) and resumes when it returns to the foreground. This prevents processing data updates when the user can't see them, saving battery and avoiding wasted work. This is the recommended pattern from Google's architecture documentation for collecting Flows in the View layer.
The Fragment gotcha: Fragment's lifecycleScope is NOT the same as its viewLifecycleOwner.lifecycleScope. The Fragment can exist without a view (back stack). Collecting UI state with the wrong scope = crash when view is null. Always use viewLifecycleOwner for UI work in Fragments.
23. What is a coroutine flow vs a sequence? When would you use Sequence over Flow?
Both are lazy, sequential, and use the same operators. The key difference: Sequence is synchronous (blocking), Flow is asynchronous (suspending). Never use Sequence for IO operations.
// Sequence — synchronous, lazy, no suspend support val seq = sequence { yield(1) yield(2) // yield(api.fetch()) ❌ cannot suspend here! } seq.filter { it > 0 }.forEach { println(it) } // blocking // Flow — asynchronous, lazy, suspend support val flow = flow { emit(1) emit(api.fetch()) // ✅ can suspend here } flow.filter { it > 0 }.collect { println(it) } // suspending // Sequence: perfect for in-memory lazy computations val fibonacci = sequence { var a = 0; var b = 1 while (true) { yield(a); val c = a + b; a = b; b = c } } fibonacci.take(10).toList() // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] // When to use which: // Sequence: pure in-memory data, no IO, CPU-bound lazy eval // Flow: IO operations, async, time-based, reactive streams // Sequence.asFlow() — convert to Flow when you need async operators val asyncFibonacci = fibonacci.asFlow().take(10)
Sequences and Flows look almost identical at the API surface — both are lazy, both use familiar operators like map, filter, and take, and both generate values on demand rather than materialising the entire collection upfront. The fundamental difference is threading and suspension. A Sequence is entirely synchronous: its producers and operators run on the calling thread, blocking it for the duration. The yield() function in a sequence builder is not a coroutine suspension point — it yields to the consumer, but both run on the same thread in lockstep.
Flow is asynchronous: emit() is a suspend function, operators like map and filter can contain suspend calls, and you can switch dispatchers mid-stream with flowOn(). This makes Flow the only option when your data production involves IO (network, database, sensors) or time (delays, debounce). You literally cannot call a suspend function from inside a sequence builder — the compiler won't allow it. Trying to use a Sequence for async work forces awkward workarounds that defeat the purpose.
Sequences genuinely excel at in-memory lazy computation: generating infinite mathematical series (Fibonacci, primes), lazily processing large in-memory collections where you only need the first N results, or building lazy pipelines over data structures that are already in RAM. They're lighter than Flow for these cases — no coroutine machinery overhead. But be careful about performance: if a Sequence operator chain runs on the main thread with a large dataset, it'll block the UI. For in-memory work combined with any form of concurrency or dispatcher switching, convert to Flow with .asFlow() and process on Dispatchers.Default.
Performance trap: Sequence operators run on the calling thread synchronously. If you use a Sequence for a large in-memory transformation on the main thread, you'll ANR. For in-memory + concurrent, use Flow with Dispatchers.Default. For pure lazy in-memory, Sequence is lighter than Flow.
24. How do you convert callback-based APIs to coroutines using suspendCoroutine and suspendCancellableCoroutine?
Legacy libraries use callbacks. suspendCancellableCoroutine wraps them into suspend functions — the modern, clean way to bridge callback APIs with coroutines.
// Old callback API interface LocationCallback { fun onLocation(location: Location) fun onError(e: Exception) } fun getLocation(callback: LocationCallback) { /* ... */ } // suspendCancellableCoroutine — bridge to coroutines suspend fun getLocationAsync(): Location = suspendCancellableCoroutine { cont -> val callback = object : LocationCallback { override fun onLocation(location: Location) { cont.resume(location) // success — resume coroutine } override fun onError(e: Exception) { cont.resumeWithException(e) // failure — throw in coroutine } } getLocation(callback) // Cancellation cleanup — called if coroutine is cancelled cont.invokeOnCancellation { locationManager.removeUpdates(callback) } } // Usage — now just a suspend function! viewModelScope.launch { val loc = getLocationAsync() // suspends until callback fires showOnMap(loc) } // callbackFlow — for repeated callbacks → Flow fun locationUpdates(): Flow<Location> = callbackFlow { val listener = LocationListener { trySend(it) } locationManager.addUpdates(listener) awaitClose { locationManager.removeUpdates(listener) } // awaitClose — cleanup when flow is cancelled }
Most Android APIs predate coroutines and use callbacks. suspendCancellableCoroutine is the bridge that wraps a one-shot callback into a suspend function. The lambda receives a Continuation: you register your callback, and in the callback's success path you call continuation.resume(value) to deliver the result and resume the suspended coroutine; in the failure path you call continuation.resumeWithException(e) to throw the exception at the suspension point. The calling coroutine suspends until one of these is called — it looks like a blocking call but releases the thread while waiting.
The "Cancellable" in the name is critical. invokeOnCancellation { } registers a cleanup block that runs if the coroutine is cancelled while waiting. Without it, if the user leaves the screen mid-operation, the callback is never removed — the listener stays registered, fires when the async operation eventually completes, and calls continuation.resume() on an already-cancelled coroutine (a no-op, but the listener itself may hold a reference to a destroyed context). Always unregister callbacks in invokeOnCancellation: cancel network calls, remove location listeners, deregister receivers.
For APIs that deliver repeated values — location updates, Bluetooth events, sensor readings, Firebase listeners — callbackFlow { } is the right tool. It creates a cold Flow backed by a Channel: you register your listener, use trySend(value) (non-suspending, safe to call from any thread) inside the callback, and provide an awaitClose { } block that runs when the flow is cancelled or the collector stops. The awaitClose is not optional — without it, the function throws an exception because a runaway listener with no cleanup is an error. This pattern cleanly converts any push-based API into a reactive Flow.
callbackFlow is how you convert location updates, Bluetooth events, or sensor readings into Flow. The awaitClose block is mandatory — without it, listeners accumulate every time the flow is collected, causing memory leaks.
25. Scenario: Your app makes 3 API calls — user, orders, and recommendations. Some can run in parallel. How do you structure this?
This is a classic concurrency design question. The correct structure depends on dependencies between calls — independent calls should be parallel, dependent calls sequential.
// Scenario: user (needed) → orders (needs userId) → recommendations (independent) @HiltViewModel class DashboardViewModel @Inject constructor( private val userRepo: UserRepository, private val orderRepo: OrderRepository, private val recoRepo: RecoRepository ) : ViewModel() { fun loadDashboard() { viewModelScope.launch { _state.value = DashboardState.Loading // Parallel: user + recommendations (independent) val userDeferred = async { userRepo.getUser() } val recoDeferred = async { recoRepo.get() } // Wait for user first (orders depend on it) val user = userDeferred.await () // Sequential: orders depend on userId val orders = orderRepo.getOrders(user.id) // Recos may already be done by now val recos = recoDeferred.await () _state.value = DashboardState.Success (user, orders, recos) } } } // With supervisorScope — partial failure OK fun loadDashboardResilient() { viewModelScope.launch { supervisorScope { val userDeferred = async { runCatching { userRepo.getUser() } } val recoDeferred = async { runCatching { recoRepo.get() } } val user = userDeferred.await ().getOrNull () ?: return@supervisorScope val orders = runCatching { orderRepo.getOrders(user.id) }.getOrDefault (emptyList ()) val recos = recoDeferred.await ().getOrDefault (emptyList ()) _state.value = DashboardState.Success (user, orders, recos) } } }
The first step in answering any parallel API question is drawing the dependency graph — then the solution writes itself. In this scenario: the user profile can be fetched independently. Recommendations are also independent — they might use a generic user ID or be pre-personalised. But orders require the userId from the user profile, so orders must be sequential after user. This gives you two parallel tracks: user (→ orders) runs on one track, recommendations on another, and you wait for both tracks to finish before assembling the result.
The implementation uses async to start the parallel work immediately, then await() in dependency order. Start both userDeferred = async { fetchUser() } and recoDeferred = async { fetchRecos() } at the same time. Call userDeferred.await() first (because orders need the userId), then call orderRepo.getOrders(user.id) sequentially, then recoDeferred.await() — which may already be complete by this point since it was running in parallel all along. Total wall-clock time is approximately max(userTime, recoTime) + ordersTime, not the sum of all three. That's the quantitative win you should articulate in the interview.
For production resilience, wrap the parallel work in supervisorScope with runCatching around each call. Recommendations failing shouldn't prevent the user and orders from displaying — recommendations are non-critical and can degrade to an empty list. User data failing is critical and should propagate (the screen can't function without it). The architectural principle is to separate required data from optional enhancements, and use supervisorScope to isolate optional failures while still propagating critical ones. This kind of nuanced answer — considering partial failures, not just the happy path — is what distinguishes a senior-level response from a mid-level one.
Draw the dependency graph before coding: user → orders (sequential). user ‖ recommendations (parallel). Total time: max(userTime, recoTime) + ordersTime instead of userTime + ordersTime + recoTime. Showing you can reason about timing complexity separates senior from mid-level answers.
26. Scenario: A network request is taking too long. How do you implement a timeout in coroutines?
Coroutines provide withTimeout and withTimeoutOrNull for clean timeout handling — far simpler than the thread-based alternatives.
// withTimeout — throws TimeoutCancellationException try { val user = withTimeout(5000L) { // 5 second limit api.fetchUser() } _state.value = UiState.Success (user) } catch (e: TimeoutCancellationException) { _state.value = UiState.Error ("Request timed out") } // withTimeoutOrNull — returns null on timeout (cleaner) val user = withTimeoutOrNull(5000L) { api.fetchUser() } _state.value = if (user != null) UiState.Success (user) else UiState.Error ("Timed out") // OkHttp timeout — different layer (transport level) val okHttpClient = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .build() // Best practice: BOTH layers of timeout // OkHttp: network-level (connect, read, write) // withTimeout: business-level (overall operation) // Retry with exponential backoff + timeout suspend fun fetchWithRetry(): User { var delay = 1000L repeat(3) { attempt -> val result = withTimeoutOrNull(5000L) { api.fetchUser() } if (result != null) return result if (attempt < 2) delay(delay.also { delay *= 2 }) } throw IOException("Failed after 3 attempts") }
withTimeout vs withTimeoutOrNull — the choice between the two comes down to how you want to signal failure. withTimeout throws TimeoutCancellationException when the block exceeds the limit, so you must wrap it in a try-catch. withTimeoutOrNull returns null instead — cleaner for most cases where "no result" is a valid state you can check with a null comparison. In interviews, prefer withTimeoutOrNull unless the timeout itself is an exceptional condition requiring different handling logic.
Layered timeouts — coroutine timeouts and OkHttp timeouts operate at different layers and should both be set. OkHttp handles transport-level timeouts: TCP connect, SSL handshake, reading the response body. withTimeout handles the business-level constraint: "this entire operation — including retries, parsing, and database writes — must complete within N seconds." Set OkHttp to generous values (30s read, 15s connect) and withTimeout to your UX requirement (5–10s). If OkHttp fires first, it throws an IOException which propagates through the coroutine normally.
Critical pitfall — TimeoutCancellationException is a subtype of CancellationException. Never catch CancellationException broadly and swallow it — doing so will prevent coroutine cancellation from working correctly and can cause resource leaks. Always either re-throw it or catch the more specific TimeoutCancellationException only. In catch (e: Exception) blocks, add if (e is CancellationException) throw e as the first line to stay safe.
Use both OkHttp timeout AND withTimeout. OkHttp handles TCP-level timeouts. withTimeout handles the business case: "if this entire operation takes more than 5 seconds, fail gracefully." They serve different purposes at different layers.
27. Scenario: You need to retry a failed network call with exponential backoff. How do you implement this with coroutines?
Retry with exponential backoff is a production pattern for resilient network calls. Coroutines make it clean with a simple loop and delay.
// Clean retry with exponential backoff suspend fun <T> retryWithBackoff( maxRetries: Int = 3, initialDelay: Long = 1000L, maxDelay: Long = 10_000L, factor: Double = 2.0, block: suspend () -> T ): T { var currentDelay = initialDelay repeat(maxRetries) { attempt -> try { return block() } catch (e: Exception) { if (e is CancellationException) throw e // never swallow! if (attempt == maxRetries - 1) throw e // last attempt: rethrow println("Attempt ${attempt+1} failed, retrying in ${currentDelay}ms") delay(currentDelay) currentDelay = minOf(currentDelay * factor.toLong (), maxDelay) } } throw IllegalStateException("Should not reach here") } // Usage viewModelScope.launch { val user = retryWithBackoff(maxRetries = 3, initialDelay = 500L) { api.fetchUser() } _state.value = UiState.Success (user) } // Using Flow retry operators repo.getUser() .retry(3) { cause -> cause is IOException // only retry network errors } .retryWhen { cause, attempt -> if (cause is IOException && attempt < 3) { delay(1000L * (2.0.pow(attempt.toDouble ())).toLong ()) true // retry } else false // don't retry } .collect { updateUi(it) }
Why exponential backoff — when a server is under load or briefly unavailable, hammering it with rapid retries makes the problem worse. Exponential backoff gives the server breathing room: wait 1 second, then 2, then 4, then 8. Each attempt doubles the delay. In practice, cap the maximum delay at 10–30 seconds so a long outage doesn't leave users waiting indefinitely. Adding jitter (a random offset) prevents the "thundering herd" problem where thousands of clients all retry at exactly the same moment after a shared outage.
CancellationException handling — the most common mistake in retry implementations is catching all exceptions including CancellationException. If you catch it and keep retrying, the coroutine becomes impossible to cancel. Always check if (e is CancellationException) throw e inside your catch block, or use catch (e: IOException) to only catch recoverable errors. The coroutine runtime uses CancellationException as its cancellation signal — swallowing it breaks the entire structured concurrency model.
Flow-based retry — for Flow pipelines, use Flow.retry(retries) { cause -> cause is IOException } for simple cases, or Flow.retryWhen { cause, attempt -> ... } when you need to inspect the attempt count and implement custom backoff logic inside the lambda. retryWhen gives you full control: you can implement exponential delay with delay(minOf(1000L * 2.0.pow(attempt).toLong(), 30000L)) directly inside the predicate. Return true to retry, false to propagate the error.
Always add jitter to backoff in production — multiple clients retrying at exactly the same intervals causes "thundering herd" at your server. Add delay += Random.nextLong(0, currentDelay/2) to spread retries.
28. What is Flow.debounce and Flow.throttle? Give real-world examples.
Debounce waits for a pause before emitting. Throttle limits emission rate. Both are critical for search fields, button clicks, and real-time updates.
// debounce — wait for PAUSE before emitting // Resets timer on each emission searchQueryFlow .debounce(300) // wait 300ms after last keystroke .collect { query -> search(query) } // User types "android" — only fires ONCE, 300ms after "d" // throttleFirst — emit first value, ignore for duration // Not in stdlib — implement manually fun <T> Flow<T>.throttleFirst(periodMs: Long): Flow<T> = flow { var lastEmit = 0L collect { value -> val now = System.currentTimeMillis() if (now - lastEmit >= periodMs) { lastEmit = now emit(value) } } } // Real-world use cases: // debounce(300): search-as-you-type // debounce(500): auto-save form inputs // throttleFirst: button clicks (prevent double-submit) // debounce(1000): scroll position persistence // Sample operator — emit first in each window import kotlinx.coroutines.flow.sample locationFlow .sample(1000) // take one value every 1 second max .collect { updateMap(it) } // distinctUntilChanged — deduplicate consecutive emissions userFlow .map { it.name } .distinctUntilChanged() // only emit when name actually changes .collect { updateNameView(it) }
debounce — waits for a pause before emitting. If values keep arriving faster than the timeout, none are emitted until the stream goes quiet. This is the go-to for search fields: you only want to fire an API call once the user has paused typing, not after every keystroke. A 300–500ms debounce means someone typing "android" triggers one request (after the final letter), not seven. Also useful for auto-save (don't persist on every character) and screen resize events (don't recalculate layout 60 times per second).
throttleFirst vs sample — throttleFirst emits the first value in each time window and ignores the rest, making it ideal for button clicks where you want to respond immediately but prevent double-submissions. sample takes the last value at each time window boundary — useful for periodic snapshots of a continuously updating stream like GPS coordinates, stock prices, or sensor readings where you want one reading per second regardless of how many raw updates arrive.
distinctUntilChanged — skips consecutive duplicate values without any time dimension. If a StateFlow emits the same value twice in a row, distinctUntilChanged prevents downstream recomposition. Combine it with debounce for the cleanest search pipeline: searchQueryFlow.debounce(300).distinctUntilChanged().flatMapLatest { query -> searchApi(query) }. The debounce waits for a pause, distinctUntilChanged prevents refetching if the query didn't actually change (e.g., user types then deletes to restore the same text).
Button double-click prevention is a common production bug. Wrap button clicks in a Flow with throttleFirst(1000) or use a simple flag. The coroutine approach is cleaner than managing booleans manually — and testable with virtual time in runTest.
29. Scenario: You need to implement a polling mechanism — check server every 5 seconds for order status. How do you implement this cleanly?
Polling with coroutines is elegant — a simple loop with delay. The scope lifecycle handles cleanup automatically, and you can stop on success or error.
// Clean polling with coroutines @HiltViewModel class OrderViewModel @Inject constructor( private val repo: OrderRepository ) : ViewModel() { private var pollingJob: Job? = null fun startPolling(orderId: String) { pollingJob?.cancel() // cancel any existing polling pollingJob = viewModelScope.launch { while (isActive) { val status = runCatching { repo.getOrderStatus(orderId) } .getOrNull () when (status) { OrderStatus.DELIVERED -> { _state.value = UiState.Success (status) break // stop polling on terminal state } OrderStatus.FAILED -> { _state.value = UiState.Error ("Order failed") break } else -> _state.value = UiState.Loading (status) } delay(5_000L) // wait 5 seconds } } } fun stopPolling() { pollingJob?.cancel() } override fun onCleared() { pollingJob?.cancel() } } // Elegant Flow-based polling fun pollOrderStatus(orderId: String): Flow<OrderStatus> = flow { while (true) { emit(repo.getOrderStatus(orderId)) delay(5_000L) } }.takeWhile { it != OrderStatus.DELIVERED && it != OrderStatus.FAILED }
The polling loop pattern — the correct coroutine polling loop is while (isActive) { fetchStatus(); delay(5000) }. isActive checks the coroutine's cancellation state on every iteration, so the loop exits cleanly when the parent scope is cancelled. Never use while (true) without an isActive check — you'll create a coroutine that ignores cancellation and becomes unkillable. The delay() inside the loop is also cancellation-aware, so even mid-delay the coroutine stops immediately on cancellation.
Terminal state detection — polling for something like order status requires stopping when you reach a final state. Add a break (or return) when the status is terminal: if (status == DELIVERED || status == FAILED) break. Without this, you'll poll forever even after the order is delivered — a zombie coroutine wasting network resources. Also cancel any previous polling job before starting a new one: pollingJob?.cancel(); pollingJob = scope.launch { ... } to handle cases like the user switching between orders.
Flow-based polling — a more composable approach wraps polling in a Flow: flow { while(true) { emit(api.getStatus()); delay(5000) } }.takeWhile { it \!= DELIVERED }. The takeWhile operator automatically completes the flow when the predicate returns false, so no manual break is needed. This approach integrates naturally with Flow operators — add distinctUntilChanged() to skip re-renders when the status hasn't changed, or catch { } for error handling. The Flow is cold, so collection starts when the UI subscribes and stops when the lifecycle scope cancels.
Always cancel the previous polling Job before starting a new one. If the user taps "refresh" twice, two polling loops will run simultaneously without this guard. Storing the Job reference and cancelling it is the standard pattern.
30. What is the difference between coroutines and threads in terms of memory and scheduling?
Coroutines and threads both achieve concurrency but with completely different resource models. A thread is an OS-level resource -- creating thousands is expensive. A coroutine is a lightweight suspended computation -- you can have millions with minimal overhead because they share a small thread pool.
// Threads -- OS managed, ~1MB stack each val t = Thread { doWork() }; t.start() // 10,000 threads ≈ 10GB RAM -- impractical // Coroutines -- suspend and resume on a shared thread pool val scope = CoroutineScope(Dispatchers.IO) repeat(100_000) { scope.launch { delay(1000) } } // 100k coroutines, ~handful of threads // Suspension: coroutine releases its thread while waiting suspend fun fetchData(): Data { val result = withContext(Dispatchers.IO) { api.call() } // suspends, thread free return result // resumes on original dispatcher }
Thread cost — an OS thread allocates approximately 1MB of stack memory by default and requires OS-level context switching. Creating 1,000 threads means 1GB of stack memory plus the overhead of the OS scheduler switching between them. Context switching involves saving and restoring CPU register state, flushing CPU caches, and updating the OS scheduling tables — significant overhead that compounds with thread count. On Android, the UI thread pool for Dispatchers.IO is capped at 64 threads precisely because beyond that point, thread overhead starts degrading performance.
Coroutine cost — a coroutine allocates roughly 1KB of heap memory for its state object. You can create 100,000 coroutines and they all share the same small pool of ~64 IO threads. When a coroutine hits a suspension point (like delay() or an I/O call), it saves its local state to the heap, releases the thread back to the pool, and another coroutine picks up that thread immediately. When the suspension completes (the delay fires, the I/O returns), the coroutine is scheduled to resume on whichever thread is available next. This is cooperative multitasking at the language level — no OS involvement, no context switch overhead.
Why this matters in interviews — the key insight interviewers want to hear is: coroutines don't eliminate threads, they multiplex many concurrent operations onto a small number of threads efficiently. delay(1000) suspends the coroutine for 1 second without occupying a thread at all. 1,000 simultaneous delay(1000) calls use exactly zero threads while waiting. Structured concurrency adds the final piece: the coroutine's scope defines its lifetime, and cancelling the scope cascades to all child coroutines — unlike threads which have no built-in parent-child relationship.
Cooperative scheduling is both a strength and a weakness. CPU-bound coroutines with no suspension points block the entire thread they're running on, starving other coroutines. Always use Dispatchers.Default for CPU work AND add ensureActive() or yield() in tight loops.
31. What is the actor pattern in coroutines? How does it help with concurrent state?
The actor pattern confines mutable state to a single coroutine. All mutations happen via message-passing through a channel — eliminating shared state and race conditions.
// Actor pattern — state confined to one coroutine sealed class CounterMsg { object Increment : CounterMsg() object Decrement : CounterMsg() class GetCount(val response: CompletableDeferred<Int>) : CounterMsg() } fun CoroutineScope.counterActor() = actor<CounterMsg> { var count = 0 // private, never shared! for (msg in channel) { when (msg) { is CounterMsg.Increment -> count++ is CounterMsg.Decrement -> count-- is CounterMsg.GetCount -> msg.response.complete (count) } } } // Usage — all mutations via messages, no locks needed! val counter = viewModelScope.counterActor() counter.send(CounterMsg.Increment) counter.send(CounterMsg.Increment) val response = CompletableDeferred<Int>() counter.send(CounterMsg.GetCount(response)) println(response.await ()) // 2 — always correct // Modern alternative: Redux-style with Channel val actions = Channel<Action>() viewModelScope.launch { var state = AppState() for (action in actions) { state = reduce(state, action) // pure function _uiState.value = state // publish to UI } }
The actor pattern — an actor is a single coroutine that owns a piece of mutable state exclusively. All mutations happen by sending messages through a Channel; the actor processes them sequentially. Since only one coroutine ever touches the state, there are no race conditions and no locks needed. The mutable variable might as well be a regular var — it's never accessed from multiple threads simultaneously. This is the "share by communicating" philosophy from Go and Erlang, applied to Kotlin coroutines.
Type-safe messages with sealed classes — model all mutations as a sealed class hierarchy: sealed class CounterMsg { object Increment : CounterMsg(); object Decrement : CounterMsg(); data class GetValue(val response: CompletableDeferred<Int>) : CounterMsg() }. The actor's for (msg in channel) loop uses an exhaustive when expression, so adding a new message type causes a compile error if any handler is missing. CompletableDeferred enables request-response semantics — the sender suspends on deferred.await() until the actor processes the message and completes the deferred.
Redux/MVI scaling — for ViewModels, the actor pattern scales into the full Redux architecture: a Channel<Intent> accepts all user actions (as sealed class intents), a reduce function maps (state, intent) → new state, and MutableStateFlow broadcasts state to the UI. This is the heart of MVI pattern — single actor, single state, fully deterministic. Note that actor { } builder in kotlinx.coroutines is marked experimental; the production-safe approach is constructing the pattern manually with Channel(UNLIMITED) and a processing coroutine, which gives you the same guarantees without the experimental API warning.
The actor pattern is the theoretically correct answer to concurrent state mutation. In practice, MVI architecture (Model-View-Intent) in Android is the actor pattern — actions in, state out, single reducer coroutine. Connecting actor to MVI shows architectural depth.
32. What is Flow.zip vs Flow.combine? When do you choose each?
zip pairs values 1:1 and waits for both. combine emits whenever either source changes — different semantics for different use cases.
// zip — strict 1:1 pairing, waits for both val names = flowOf("Alice", "Bob", "Carol") val scores = flowOf(100, 200) names.zip(scores) { name, score -> "$name: $score" }.collect { println(it) } // "Alice: 100" // "Bob: 200" // Carol dropped — zip stopped when shorter flow completed // combine — emits when EITHER changes, uses latest of other val query = MutableStateFlow("") val filters = MutableStateFlow(emptyList<String>()) query.combine(filters) { q, f -> search(q, f) }.collect { showResults(it) } // query changes → emits with latest filters // filters change → emits with latest query // combine with 3+ flows combine(userFlow, settingsFlow, locationFlow) { user, settings, location -> HomeUiState(user, settings, location) } // Real-world example: search with filters and sort combine( _searchQuery.debounce(300), _selectedCategory, _sortOrder ) { query, category, sort -> repo.search(query, category, sort) // triggers new search }.flatMapLatest { it } // cancels previous search .collect { _results.value = it } // When to use: // zip: ordered pairing, parallel producers, 1:1 relationship // combine: reactive state from multiple sources, any change triggers
zip — ordered 1:1 pairing — zip waits until both flows have emitted one value, pairs them, then waits for the next pair. The output flow has the same number of elements as the shorter input flow, then completes. Order is always preserved: the 3rd element of flow A is always paired with the 3rd element of flow B. Use zip when you're combining parallel API calls that produce matching ordered results — two lists that should be merged element-by-element, like matching user profiles with their computed scores.
combine — latest-wins reactive merging — combine caches the latest value from each source and re-emits whenever any source changes. If flow A emits 3 times before B emits once, combine produces 3 outputs (each with B's initial value paired with each of A's values). This makes combine the right operator for reactive UI state derived from multiple independent sources — search query + active filters + sort order + user preferences. Any change to any source immediately re-runs the combination and updates the UI.
The complete search pipeline — the production pattern for a search screen combines all inputs and drives a network call: combine(searchQueryFlow, filterFlow, sortFlow) { query, filter, sort -> SearchParams(query, filter, sort) }.debounce(300).distinctUntilChanged().flatMapLatest { params -> searchRepository.search(params) }. combine merges the inputs, debounce waits for the user to stop changing things, distinctUntilChanged prevents duplicate searches, and flatMapLatest cancels the in-flight network call the moment the parameters change. This four-operator chain is the entire search UX in 40 characters.
combine is the reactive search filter pattern: whenever query, category, or sort changes, combine fires with all three current values. The interview question "search screen with multiple filters" is almost always solved with combine + debounce + flatMapLatest.
33. Scenario: Your app does a lot of image processing on photos selected from gallery. The UI freezes. How do you fix it using coroutines?
Image processing is CPU-intensive — it should run on Dispatchers.Default. Combining it with a proper scope and progress updates keeps the UI responsive.
// Problem: image processing on Main thread → UI freeze // ❌ Wrong fun onImagesSelected(uris: List<Uri>) { val results = uris.map { processImage(it) } // blocks main thread! showResults(results) } // ✅ Correct — Dispatchers.Default for CPU work fun onImagesSelected(uris: List<Uri>) { viewModelScope.launch { _state.value = ProcessState.Loading (0, uris.size ) val results = withContext(Dispatchers.Default) { uris.mapIndexed { index, uri -> ensureActive() // check cancellation between images val result = processImage(uri) // Update progress on Main withContext(Dispatchers.Main) { _state.value = ProcessState.Loading (index + 1, uris.size ) } result } } _state.value = ProcessState.Done (results) } } // Parallel processing — process all images concurrently val results = withContext(Dispatchers.Default) { uris.map { uri -> async { processImage(uri) } // parallel! }.awaitAll() } // Limit parallelism — avoid overwhelming CPU val limitedDispatcher = Dispatchers.Default.limitedParallelism(4) uris.map { async(limitedDispatcher) { processImage(it) } }. awaitAll ()
Dispatcher choice for CPU work — image processing is CPU-bound, not I/O-bound. Use Dispatchers.Default, not Dispatchers.IO. IO has up to 64 threads and is optimized for waiting (network, disk). Default has Runtime.availableProcessors() threads (typically 4–8 on modern devices) and is optimized for computation. Running heavy CPU work on IO doesn't crash anything, but it wastes threads that could be serving I/O and can cause contention. Dispatchers.Default is the right choice for image processing, JSON parsing, encryption, and any CPU-intensive algorithm.
Cancellation checkpoints — CPU-bound work doesn't suspend naturally, so you must insert manual cancellation checkpoints. ensureActive() throws CancellationException if the coroutine has been cancelled — call it at logical boundaries like between images or between processing stages. This prevents a cancelled coroutine from completing a 30-second batch job before realizing it was cancelled. For tight loops, you can also use yield(), which both checks cancellation and gives other coroutines a chance to run on the same thread.
Progress reporting and memory control — to update the UI during processing, use withContext(Dispatchers.Main) { updateProgress(i, total) } inline within the Default block. The context switch is cheap — a coroutine suspension with immediate resume on a different dispatcher. To prevent processing 100 images simultaneously and exhausting heap memory, use Dispatchers.Default.limitedParallelism(3) to create a dispatcher that runs at most 3 concurrent processing jobs, or process in chunked() batches sequentially. Both approaches give you predictable memory usage regardless of batch size.
Dispatchers.Default is often overlooked in favour of IO. IO is for blocking/waiting (network, disk). Default is for CPU work (image processing, sorting, JSON parsing). Using IO for CPU work wastes the IO thread pool and can block network calls.
34. What is coroutine flow's catch operator vs try-catch? How do you handle errors in a Flow chain?
Flow error handling has specific rules — catch only handles upstream errors, and exceptions in collect must be handled with try-catch. Understanding the asymmetry prevents bugs.
// catch — handles UPSTREAM errors only repo.getUsers() .map { transform(it) } // ← catch handles errors from here .catch { e -> emit(emptyList()) // emit fallback _error.value = e.message } .collect { // ← catch does NOT handle errors here! updateUi(it) } // Error in collect — must use try-catch try { flow.collect { value -> updateUi(value) // if this throws, catch() won't catch it } } catch (e: Exception) { handleError(e) } // Complete error handling chain viewModelScope.launch { repo.getUsers() .onStart { _loading.value = true } .catch { e -> _loading.value = false _error.value = e.message emit(emptyList()) } .onCompletion { _loading.value = false } .collect { _users. value = it } } // Transparent error forwarding in Flow fun safeFlow(): Flow<User> = flow { emit(api.getUser()) }.catch { e -> if (e is NetworkException) emit(User.EMPTY) else throw e // re-throw unexpected errors }
The catch operator's scope — Flow.catch is an intermediate operator that only handles exceptions thrown by upstream operators and the flow builder itself. It does NOT catch exceptions thrown inside the collect { } terminal operator. This asymmetry trips up many developers. If your collect { } lambda throws, the exception propagates to the calling coroutine's scope — not to catch. This design is intentional: it maintains exception transparency so downstream errors don't silently disappear into an upstream handler.
Handling both sides — to handle errors in both the flow and the collector, wrap the entire collect call in a try-catch: try { flow.catch { emit(fallback) }.collect { value -> processValue(value) } } catch (e: Exception) { handleCollectError(e) }. The catch operator handles upstream failures gracefully (optionally emitting a fallback), while the try-catch handles errors that occur during processing in collect. For ViewModels, this pattern lets you emit an error state to the UI without crashing.
Transparent error handling — inside catch, only handle errors you know how to recover from. Re-throw anything unexpected: .catch { e -> if (e is NetworkException) emit(cachedData) else throw e }. This keeps your error handling specific and prevents silently swallowing bugs. Never put a bare .catch { } that does nothing — you'll make debugging production issues nearly impossible. And always remember: never catch CancellationException — if you do, coroutine cancellation will break silently and you'll have uncancellable zombie coroutines.
The catch operator's asymmetry is a common interview trap. "Why is my catch not working?" — because the exception is thrown inside collect{}, which is downstream. Upstream errors (inside flow{}, map{}, filter{}) are caught. Downstream errors (inside collect{}) are not.
35. What is structured concurrency's parent-child relationship? How does exception propagation work?
In structured concurrency, parent and child coroutines have a strict relationship — parent waits for children, children cancel with parent, and exceptions propagate up unless isolated.
// Parent-child relationship rules: // 1. Parent COMPLETES only after all children complete // 2. Parent CANCELLATION cancels all children // 3. Child FAILURE propagates to parent (with Job) // 4. Child FAILURE is isolated (with SupervisorJob) // Rule 1: parent waits for children viewModelScope.launch { launch { delay(1000); println("child 1") } launch { delay(2000); println("child 2") } println("parent starting") // Parent coroutine ends at 2 seconds, not immediately } // Rule 3: child failure propagates up val scope = CoroutineScope(Job()) scope.launch { launch { throw IOException("Child failed") // propagates up! } launch { doWork() } // cancelled due to sibling failure } // BOTH child coroutines cancelled, scope's Job is failed // Rule 4: SupervisorJob isolates failures val supervisedScope = CoroutineScope(SupervisorJob()) supervisedScope.launch { launch { throw IOException("fails") } // isolated launch { doWork() } // continues! } // coroutineScope{} — structured scope, propagates failure // supervisorScope{} — supervisor scope, isolates failure suspend fun loadAll() = supervisorScope { val a = async { fetchA() } // isolated val b = async { fetchB() } // isolated Results(runCatching { a.await () }.getOrNull (), runCatching { b.await () }.getOrNull ()) }
The three rules of structured concurrency — parent-child coroutine relationships follow three invariants: (1) a parent always waits for all its children to complete before it completes itself; (2) cancelling a parent automatically cancels all its children; (3) if a child fails with an exception, that exception propagates to the parent. These rules together guarantee that coroutines can't outlive their scope, resources are always cleaned up, and errors are never silently lost. Understanding these three rules is the foundation of structured concurrency.
Job vs SupervisorJob — failure propagation — with a regular Job, one child's failure cancels the parent and all siblings. This is correct for atomic operations: if one part fails, the whole operation should fail. With SupervisorJob, each child fails independently — other children continue running. Use SupervisorJob in viewModelScope and for independent parallel work where one failure shouldn't cancel everything else. The key distinction: coroutineScope { } creates a regular Job scope (one failure cancels all), while supervisorScope { } isolates failures.
Practical application — choose based on atomicity requirements. Loading a screen that requires user data, orders, AND recommendations: use supervisorScope with runCatching on recommendations (non-critical) but let user data failures propagate (critical). Uploading a transaction where network call + database write must both succeed: use coroutineScope so a database failure cancels the network call too. In real codebases, supervisorScope is used far more commonly in Android because most parallel work (loading multiple independent data sources) is better served by partial-failure tolerance than all-or-nothing atomicity.
The choice between coroutineScope and supervisorScope is architectural: "Is this operation atomic (all or nothing) or resilient (partial success is OK)?" Downloading a ZIP file → coroutineScope (all parts needed). Dashboard widgets → supervisorScope (some widgets failing is OK).
36. What is Dispatchers.Main.immediate and when do you need it?
Main.immediate skips the message queue if already on the main thread — executing immediately rather than posting to the handler. This avoids one frame of latency in common scenarios.
// Dispatchers.Main — always posts to main thread message queue // Even if already on main thread → 1 frame latency viewModelScope.launch(Dispatchers.Main) { _state.value = UiState.Loading // posted to queue (extra frame) } // Dispatchers.Main.immediate — runs immediately if already on main viewModelScope.launch(Dispatchers.Main.immediate ) { _state.value = UiState.Loading // runs immediately on main thread } // viewModelScope uses Main.immediate by default // That's why immediate UI updates work without frame delay // When you'd notice the difference: // Dispatchers.Main: state update visible 1 frame later // Dispatchers.Main.immediate: state update visible THIS frame // Example where it matters — loading flash prevention // With Main: brief empty state flash before Loading shows // With Main.immediate: Loading shows on same frame as trigger // In tests — TestCoroutineScheduler controls both // Use StandardTestDispatcher for predictable test behavior val testDispatcher = StandardTestDispatcher() Dispatchers.setMain(testDispatcher) // override for tests
How Main vs Main.immediate differ — Dispatchers.Main always posts a message to the Android main thread's Handler queue, even if you're already on the main thread. This means there's a frame of delay: post → Handler processes → code runs. Dispatchers.Main.immediate checks: "Am I already on the main thread?" If yes, it executes the block synchronously right now without going through the Handler queue. If no, it posts normally. That one frame of difference is meaningful for UI responsiveness — it's the difference between the state update happening in the current frame vs the next frame.
Why viewModelScope uses Main.immediate — viewModelScope is configured with Dispatchers.Main.immediate as its default dispatcher. This means when you call a suspend function from a ViewModel and it resumes on the main thread, the UI update happens immediately — no posting, no extra frame latency. This is why StateFlow.value = newState inside viewModelScope.launch triggers an immediate Compose recomposition on resume. If viewModelScope used plain Main instead, every state update would be one frame behind, causing subtle UI jitter.
Practical impact and testing — the most visible practical effect of Main.immediate is eliminating the "loading flash" when launching a coroutine from the main thread. Without it, you'd set loading=true, post back to main, then see it render — in that one extra frame, the old UI state is still visible. With Main.immediate, the loading state is set and rendered in the same frame. For testing, use Dispatchers.setMain(StandardTestDispatcher()) which controls both Main and Main.immediate, giving you complete control over coroutine scheduling in unit tests without any real main thread involvement.
Most developers don't notice Main vs Main.immediate because viewModelScope already uses the right one. But if you manually create CoroutineScope(Dispatchers.Main), you might see a one-frame flash. This is a subtle but real production issue in animation-heavy UIs.
37. Scenario: You need to upload 50 files. Show progress per file, support cancellation, and don't crash if one fails.
This combines parallel execution, progress reporting, error isolation, and cancellation — a comprehensive coroutines scenario covering all major concepts.
@HiltViewModel class UploadViewModel @Inject constructor( private val uploadRepo: UploadRepository ) : ViewModel() { private val _progress = MutableStateFlow(UploadProgress(0, 0, 0)) val progress = _progress.asStateFlow() private var uploadJob: Job? = null fun uploadAll(files: List<File>) { uploadJob = viewModelScope.launch { val total = files.size var done = 0; var failed = 0 val mutex = Mutex() supervisorScope { // one failure doesn't cancel others files.map { file -> async(Dispatchers.IO) { try { uploadRepo.upload(file) mutex.withLock { done++ _progress.value = UploadProgress(done, failed, total) } } catch (e: CancellationException) { throw e // always re-throw! } catch (e: Exception) { mutex.withLock { failed++ _progress.value = UploadProgress(done, failed, total) } } } }.awaitAll () } } } fun cancel() { uploadJob?.cancel() } } data class UploadProgress(val done: Int, val failed: Int, val total: Int)
Structuring parallel uploads with supervisorScope — upload 50 files concurrently using supervisorScope { files.map { file -> async(Dispatchers.IO) { uploadFile(file) } }.awaitAll() }. The supervisorScope ensures one failed upload doesn't cancel the other 49. Each async(IO) block runs on the IO dispatcher in parallel. awaitAll() suspends until all complete and returns a list of results — any failed uploads produce exceptions that you can inspect from the result list, not as cancellations of the whole operation. This is the canonical pattern for "fan-out then collect results."
Thread-safe progress with Mutex — when multiple async blocks update a shared progress counter concurrently, you need synchronization. Mutex is the coroutine-friendly alternative to synchronized: it suspends rather than blocks. Wrap the counter update: val mutex = Mutex(); var completed = 0; mutex.withLock { completed++ }. Unlike synchronized, mutex.withLock is a suspend function — it gives up the thread while waiting for the lock instead of blocking it, which is critical on Dispatchers.IO where thread starvation can occur with many concurrent uploads.
Cancellation and cleanup — user-initiated cancellation must work correctly. Wrap the upload in try-finally: try { uploadChunk(...) } catch (e: CancellationException) { cleanupPartialUpload(); throw e } finally { closeStream() }. Re-throwing CancellationException is mandatory — it's how the cancellation signal propagates up the coroutine hierarchy. If you swallow it, the parent scope will wait forever for your coroutine to finish. The finally block always runs on cancellation, making it the right place for unconditional cleanup like closing streams or file handles.
This question tests everything at once: supervisorScope (isolation), Mutex (concurrent counter), Dispatchers.IO (network), CancellationException re-throw (cancellation), and Job tracking (cancel button). Walk through each decision explicitly — that's what senior interviewers want to see.
38. What is the difference between coroutineScope and runBlocking?
Both create a coroutine scope and wait for all children — but coroutineScope suspends (non-blocking) while runBlocking blocks the thread. runBlocking is for tests and main functions only.
// coroutineScope — suspends, non-blocking // ✅ Use inside suspend functions suspend fun loadUserAndPosts(): Pair<User, List<Post>> = coroutineScope { val user = async { fetchUser() } val posts = async { fetchPosts() } user.await () to posts.await () // suspends until both done, thread is FREE } // runBlocking — BLOCKS the thread // ❌ Never use in Android production code (ANR!) // ✅ Only for: main() functions, unit tests fun main() = runBlocking { val result = fetchData() println(result) } // In tests @Test fun testFetch() = runTest { // runTest, not runBlocking, for coroutine tests val result = fetchData() assertEquals("expected", result) } // coroutineScope properties: // ✅ Inherits parent context // ✅ Propagates cancellation // ✅ Waits for all children // ✅ Propagates exceptions // ✅ Thread released during suspension // runBlocking properties: // ✅ Can be called from non-suspend context // ❌ BLOCKS the calling thread — freezes Android UI // Use runTest{} for coroutine unit tests instead
coroutineScope — the right tool for parallel suspend work — coroutineScope { } is a suspend function that creates a child scope, launches children in parallel, then suspends (not blocks) until all children complete. It inherits the parent's Job, meaning failures propagate up and parent cancellation cascades down. Use it inside any suspend fun when you need to do multiple things in parallel and collect their results: suspend fun loadScreen() = coroutineScope { val user = async { api.getUser() }; val posts = async { api.getPosts() }; ScreenData(user.await(), posts.await()) }. This is cooperative — the calling thread is freed while waiting.
runBlocking — tests and main() only — runBlocking { } blocks the calling thread until all coroutines inside complete. It's explicitly designed for bridging coroutine code with non-coroutine code: it makes sense at the very top level (JVM main() function, legacy Java interop) or in unit tests before runTest existed. In Android, calling runBlocking on the main thread blocks the UI thread — guaranteed ANR if it takes more than 5 seconds. In production Android code, runBlocking has essentially zero valid use cases.
runTest — the correct testing replacement — runTest { } from kotlinx-coroutines-test is the modern, correct alternative for testing coroutines. It uses a TestCoroutineScheduler with virtual time: delay(60_000) inside runTest completes instantly because the test scheduler advances virtual time. This means your tests run in milliseconds even if the production code waits for long delays. runTest also handles structured concurrency correctly — it fails the test if uncaught exceptions occur in child coroutines, which runBlocking sometimes misses.
If you see runBlocking in production Android code (not tests), it's a bug. One runBlocking on the main thread with a 100ms IO call = 100ms UI freeze. In a 5-second timeout, that's an ANR. Always use coroutineScope or viewModelScope.launch.
39. Scenario: Your app should load cached data immediately, then refresh from network in background. How do you implement this?
The "stale-while-revalidate" pattern: show cache immediately, then fetch fresh data in background. This is implemented with Flow merging or sequential emissions.
// Pattern 1: Flow emission order — cache then network fun getUserWithRefresh(id: String): Flow<User> = flow { // Emit cached value immediately val cached = cache.getUser(id) if (cached != null) emit(cached) // Fetch fresh from network try { val fresh = withContext(Dispatchers.IO) { api.getUser(id) } cache.saveUser(fresh) emit(fresh) // emit updated value } catch (e: Exception) { if (cached == null) throw e // no cache → propagate error // else: cache already shown, network failed quietly } } // Pattern 2: merge — parallel cache + network fun getUserMerged(id: String): Flow<User> = merge( flowOf(cache.getUser(id)).filterNotNull(), flow { emit(api.getUser(id)) }.catch { } ) // Pattern 3: Room + Retrofit (most common in Android) fun getUser(id: String): Flow<User> { viewModelScope.launch { // Background refresh withContext(Dispatchers.IO) { val fresh = api.getUser(id) db.userDao().insertOrReplace(fresh) } } // Room Flow emits immediately from DB, then again after insert return db.userDao().observeUser(id) }
The stale-while-revalidate pattern — show cached data immediately so the user sees content instantly, then silently fetch fresh data in the background and update the UI when it arrives. This eliminates the loading spinner for returning users. Implementation: flow { emit(cache.get()); emit(api.fetch()) }. The first emission renders instantly (cache lookup is synchronous), the second updates the UI when the network call completes. The user never sees a blank screen — they see slightly stale data briefly, then fresh data.
Room + Retrofit — the production version — the most robust implementation uses Room as the source of truth with a reactive Flow: the repository returns dao.observeUsers() (a Room Flow that auto-emits when the table changes), while a separate call to api.getUsers() refreshes the database. When the network call completes and saves to Room, the Room Flow automatically emits the new data to all collectors. The UI never knows whether data came from cache or network — it just observes a Flow. This is the offline-first pattern recommended by Google's architecture guidelines.
Error handling in stale-while-revalidate — if the network call fails but cache data exists, the correct behavior is to show the cache data silently (perhaps with a small "last updated" timestamp) rather than showing a full error screen. Wrap the network call in runCatching or try-catch and only emit an error state if there's no cached data at all: val cached = cache.get(); emit(cached); try { emit(api.fetch()) } catch (e: IOException) { if (cached == null) emit(Error(e)) }. This gives you maximum data availability with appropriate error transparency.
Room + network refresh is the production answer. Room's Flow observes the database — when you update the DB from network, the Flow emits automatically. Single source of truth: DB is the source, network just refreshes it. This is the offline-first architecture pattern.
40. What is limitedParallelism? How do you control coroutine concurrency?
limitedParallelism creates a view of a dispatcher that limits how many coroutines can run concurrently — crucial for rate limiting, resource control, and avoiding server overload.
// limitedParallelism — limit concurrent coroutines val limitedIO = Dispatchers.IO.limitedParallelism(4) // Upload with max 4 concurrent uploads files.map { file -> async(limitedIO) { uploadRepo.upload(file) } }.awaitAll () // Only 4 uploads run simultaneously, others wait // Semaphore — another approach for concurrency limiting val semaphore = Semaphore(4) files.map { file -> async(Dispatchers.IO) { semaphore.withPermit { uploadRepo.upload(file) } } }.awaitAll () // Chunked processing — process N at a time files.chunked(10).forEach { chunk -> chunk.map { async { process(it) } }.awaitAll () // Process 10 at a time, then next 10 } // Use cases for limiting parallelism: // API rate limits: max N concurrent requests to server // DB connections: SQLite has limited connections // Memory: image processing — limit to avoid OOM // CPU: avoid over-saturating Default dispatcher // Default parallelism values: // Dispatchers.IO: up to 64 threads // Dispatchers.Default: CPU core count // limitedParallelism(1) = single-threaded — use for ordered processing
limitedParallelism — creates a view of an existing dispatcher that restricts how many coroutines can actively run on it simultaneously. Dispatchers.IO.limitedParallelism(5) creates a dispatcher that allows at most 5 concurrent executions, even though the underlying IO pool has 64 threads. This is useful for rate limiting API calls (don't hammer the server with 100 parallel requests), controlling database connection pool usage, or managing memory when each concurrent operation has significant per-item overhead. limitedParallelism(1) creates a single-threaded dispatcher — useful for serializing access to a shared resource without a Mutex.
Semaphore — explicit permit control — a Semaphore(permits) gives you explicit acquire/release semantics with more flexibility than limitedParallelism. Use it when different parts of your code need to share the same concurrency limit across different dispatchers: val semaphore = Semaphore(10); semaphore.withPermit { heavyOperation() }. The withPermit extension function handles acquisition and release safely, even on cancellation. Unlike limitedParallelism which is dispatcher-scoped, a Semaphore is a standalone object you can inject and share across the entire application.
Chunked processing — a simpler sequential alternative is items.chunked(10).forEach { batch -> processBatch(batch) }. Each batch of 10 is processed fully before starting the next. This guarantees at most 10 concurrent operations and requires no Semaphore or special dispatcher — just sequential suspension. The downside is that you wait for the slowest item in each batch before starting the next: if 9 items finish in 100ms but one takes 5 seconds, the next batch waits 5 seconds. limitedParallelism or Semaphore don't have this issue — they start the next item as soon as any previous item finishes.
limitedParallelism was added in Kotlin 1.6 — it replaces the old newFixedThreadPoolContext pattern. If you see newFixedThreadPoolContext in a codebase, it should be migrated to Dispatchers.IO.limitedParallelism(n). Knowing the modern API shows you're current.
41. How does Flow work with Room database? Explain the reactive database pattern.
Room's Flow integration is one of Android's most powerful patterns — the database becomes a reactive source that automatically notifies the UI of any changes.
// Room DAO with Flow @Dao interface UserDao { @Query("SELECT * FROM users") fun observeAll(): Flow<List<User>> // re-emits on any change @Query("SELECT * FROM users WHERE id = :id") fun observeUser(id: String): Flow<User?> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOrReplace(user: User) } // Repository — single source of truth pattern class UserRepository @Inject constructor( private val dao: UserDao, private val api: UserApi ) { fun observeUser(id: String): Flow<User?> = dao.observeUser(id) suspend fun refreshUser(id: String) { val fresh = withContext(Dispatchers.IO) { api.getUser(id) } dao.insertOrReplace(fresh) // Room automatically notifies observeUser() Flow! } } // ViewModel — combines observation + refresh class UserViewModel @Inject constructor(private val repo: UserRepository) : ViewModel() { val user = repo.observeUser("123") .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) init { viewModelScope.launch { repo.refreshUser("123") } } }
Room's reactive Flow support — when you declare a Room DAO method with a Flow<T> return type, Room automatically re-runs the query and emits a new value whenever any row in the queried table changes. This is backed by Room's InvalidationTracker which monitors which tables each query depends on. You get a fully reactive database with zero manual polling — any write to the table (insert, update, delete) automatically triggers a new emission to all active collectors.
Single source of truth pattern — the correct architecture is: UI observes Room, network just writes to Room. The ViewModel exposes dao.observeUser(userId) as a StateFlow. When refreshUser() is called, it fetches from the API and saves to Room: val user = api.getUser(userId); dao.insert(user). The Room Flow detects the database change and automatically emits the new user to the UI. The UI never directly receives network data — it always reads from the database. This means the offline case works for free: if the network call fails, the last-known data from Room is still displayed.
Converting to StateFlow with stateIn — Room returns a cold Flow that starts a new database observation for each collector. In a ViewModel, convert it to a hot StateFlow so multiple UI collectors (multiple Composables) share a single database subscription: val user = dao.observeUser(id).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null). WhileSubscribed(5000) keeps the database subscription alive for 5 seconds after the last collector disappears — long enough to survive screen rotation without re-querying the database, but short enough to release the subscription when the screen is truly gone.
The offline-first architecture: UI → observes Room Flow. ViewModel init → refreshes from network. Any DB write triggers the Flow. This means: app works offline, network updates are automatic, and the UI is always consistent. This is what Google's Now in Android sample demonstrates.
42. Scenario: You have a coroutine that's leaking — it keeps running after the screen is dismissed. How do you diagnose and fix this?
Coroutine leaks are a common production issue. They occur when coroutines outlive their scope — typically from GlobalScope, wrong scope choice, or missing cancellation.
// Diagnosing leaks: // 1. Memory Profiler — heap dumps show CoroutineImpl objects // 2. Debug.dumpJavaHeap() — analyze coroutine references // 3. LeakCanary — detects ViewModel/Fragment context leaks // 4. Log coroutine start/end with CoroutineName // CAUSE 1: GlobalScope — never cancelled // ❌ GlobalScope.launch { infinitePolling() } // lives forever! // ✅ viewModelScope.launch { infinitePolling() } // cancelled with VM // CAUSE 2: Wrong scope in Fragment // ❌ lifecycleScope outlives Fragment view lifecycleScope.launch { flow.collect { updateView(it) } } // ✅ viewLifecycleOwner ties to Fragment view viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { flow.collect { updateView(it) } } } // CAUSE 3: Holding Activity/Context reference in coroutine // ❌ Activity leaked if coroutine outlives it val context = requireActivity() // captured in lambda! viewModelScope.launch { delay(60_000); context.doSomething() } // ✅ Use WeakReference or pass data, not context // CAUSE 4: callbackFlow without awaitClose // ❌ Listener never removed fun leakyFlow() = callbackFlow { listener.register { trySend(it) } // Missing: awaitClose { listener.unregister() } } // ✅ Always include awaitClose fun safeFlow() = callbackFlow { listener.register { trySend(it) } awaitClose { listener.unregister() } }
GlobalScope — the most common leak — GlobalScope.launch creates a coroutine tied to the application's lifetime. It is never cancelled when a ViewModel is cleared, a Fragment is detached, or an Activity is destroyed. Every GlobalScope.launch in production code is a potential memory leak. The fix is always scope the coroutine to the appropriate lifecycle: viewModelScope in ViewModels (automatically cancelled in onCleared()), viewLifecycleOwner.lifecycleScope in Fragments. The only legitimate use of GlobalScope is for truly application-scoped work that must survive screen changes — and even then, injecting a custom CoroutineScope from Hilt is better practice.
Fragment scope gotcha — fragment.lifecycleScope lives as long as the Fragment object, which survives the view being destroyed on back-stack transactions. Collecting a Flow in lifecycleScope.launch means the collector keeps running even when the Fragment's view is gone — updating Views that no longer exist. The correct scope for view-related work in Fragments is viewLifecycleOwner.lifecycleScope, which is cancelled when onDestroyView() is called. For Flows specifically, use viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(STARTED) { flow.collect { ... } } } to stop collection in background.
callbackFlow without awaitClose — callbackFlow must always call awaitClose { removeListener() } at the end of the builder block. Without it, the listener registered inside the block is never removed when the Flow is cancelled. Every new collector re-adds the listener without removing the old one — they accumulate indefinitely. The awaitClose block runs when the flow is cancelled or completes, making it the guaranteed cleanup location. For debugging leaks, enable coroutine debug mode (-Dkotlinx.coroutines.debug), use CoroutineName("descriptive-name") in your scope builders, and check LeakCanary output and Android Studio's Memory Profiler heap dumps.
LeakCanary + CoroutineName is the debug stack. Add CoroutineName("UserFetcher") to every launch — if you see it in a heap dump after the screen is gone, it's leaked. Then trace back to which scope launched it.
43. What is the difference between delay() and Thread.sleep() in coroutines?
delay() is coroutine-aware — it suspends the coroutine without blocking the thread. Thread.sleep() blocks the OS thread — never use it inside coroutines.
// Thread.sleep() — BLOCKS the OS thread viewModelScope.launch(Dispatchers.IO) { Thread.sleep(1000) // IO thread blocked for 1 second! // While blocked: thread can't serve other coroutines // With 1000 sleep-blocked coroutines: IO pool exhausted → hang } // delay() — SUSPENDS the coroutine, frees the thread viewModelScope.launch(Dispatchers.IO) { delay(1000) // coroutine suspended, thread picks up another coroutine // 1 second later: coroutine resumed on available thread } // Practical impact on Dispatchers.IO (64 threads) // Thread.sleep(1000) × 65 coroutines → 65th coroutine waits 1+ second // delay(1000) × 1000 coroutines → all complete at ~1 second // delay() is cancellable — Thread.sleep() is not val job = viewModelScope.launch { delay(10_000) // user navigates away } job.cancel() // ✅ delay() cancelled immediately val job2 = viewModelScope.launch { Thread.sleep(10_000) // user navigates away } job2.cancel() // ❌ Thread.sleep() continues sleeping 10 seconds! // Testing: delay() respects TestCoroutineScheduler // In runTest, delay(1000) completes instantly // Thread.sleep(1000) in runTest = actual 1 second wait
Thread.sleep — blocks the thread — Thread.sleep(1000) tells the OS to put the calling thread to sleep for 1 second. During that second, the thread cannot do any other work. If you call Thread.sleep inside a coroutine on Dispatchers.IO, you've consumed one of the 64 available IO threads for the entire duration. With enough concurrent calls, you can exhaust the thread pool entirely — new coroutines queue up waiting for a thread, effectively making your app unresponsive. It's also uncancellable: Thread.sleep ignores coroutine cancellation and will block the thread for the full duration even after the coroutine's scope has been cancelled.
delay() — suspends without blocking — delay(1000) is a suspend function that schedules the coroutine to resume after 1 second and immediately releases the thread back to the pool. During that 1 second, the freed thread can execute hundreds of other coroutines. 1,000 coroutines all calling delay(1000) simultaneously require exactly zero threads while sleeping. When the delay expires, the scheduler picks any available thread from the pool to resume the coroutine — it doesn't need to be the same thread it started on. This is the fundamental efficiency advantage of coroutines over threads.
Testing advantage of delay() — delay() integrates with the TestCoroutineScheduler. Inside runTest, the scheduler tracks all pending delay() calls and advances virtual time, so delay(60_000) completes in microseconds in a test. This is how you test polling loops, retry logic with backoff, and timeout scenarios without actually waiting — the test scheduler manipulates virtual time directly. Thread.sleep is not virtual-time-aware: a test with Thread.sleep(5000) always takes 5 real seconds. This is one of the most practical testing advantages of using delay() correctly throughout your codebase.
Thread.sleep() inside coroutines is always a bug. Even on Dispatchers.IO with 64 threads — 65 sleeping threads exhausts the pool, causing the 65th coroutine to hang. Always use delay() in coroutines.
44. Scenario: Implement a real-time chat using WebSocket with automatic reconnection using Flow.
WebSocket + Flow with auto-reconnection demonstrates callbackFlow, retry operators, and lifecycle-aware collection — a senior-level architecture question.
// WebSocket Flow with automatic reconnection fun chatMessages(roomId: String): Flow<ChatMessage> = callbackFlow { val client = OkHttpClient() val request = Request.Builder().url("wss://chat.example.com/room/$roomId").build() val ws = client.newWebSocket(request, object : WebSocketListener() { override fun onMessage(ws: WebSocket, text: String) { trySend(parseMessage(text)) // non-suspending send } override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { close(t) // closes the flow with error → triggers retry } override fun onClosed(ws: WebSocket, code: Int, reason: String) { close() // server closed normally } }) awaitClose { ws.close(1000, "Client closed") } // cleanup! }.retryWhen { cause, attempt -> if (cause is IOException && attempt < 5) { val backoff = 2000L * (2.0.pow (attempt.toDouble ())).toLong () delay(minOf(backoff, 30_000L)) // max 30s backoff true // reconnect } else false // give up } // ViewModel val messages = chatMessages("room-123") .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 50)
callbackFlow for WebSocket wrapping — WebSocket listeners use callbacks, not coroutines. callbackFlow { } bridges this gap: it creates a coroutine-based Flow backed by a Channel. Inside the builder, register your listener and call trySend(message) from the listener callbacks. trySend (not send) is non-suspending — critical because callback functions cannot suspend. Always add awaitClose { webSocket.close(); removeListener() } at the end — this block runs when the Flow is cancelled and is the guaranteed cleanup location for removing the listener and closing the connection. Without awaitClose, the builder throws an exception.
Automatic reconnection with retryWhen — wrap the Flow in retryWhen { cause, attempt -> cause is IOException && attempt < 5 }. When the WebSocket disconnects and the Flow completes with an error, retryWhen re-subscribes — which re-runs the callbackFlow builder, opening a new WebSocket connection. Add exponential backoff inside the predicate: delay(minOf(1000L * 2.0.pow(attempt).toLong(), 30_000L)). Cap retries at 5 attempts to prevent infinite reconnection loops when the server is permanently down. After 5 failures, let the exception propagate to the UI as an error state.
Sharing a single connection with shareIn — a cold Flow creates a new WebSocket per collector. With multiple UI components observing WebSocket messages, you'd open multiple connections. Convert to a hot Flow: webSocketFlow().shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000)). shareIn subscribes once (opening one WebSocket), multicasts to all current collectors, and keeps the connection alive for 5 seconds after the last collector disconnects — surviving screen rotation without reconnecting. All collectors receive the same messages from the single underlying connection, and the retry logic handles reconnection transparently.
callbackFlow + retryWhen is the production WebSocket pattern. awaitClose is not optional — without it, the WebSocket stays open forever even when no one is collecting. shareIn ensures only ONE WebSocket connection per room regardless of how many UI components observe it.
45. What is the difference between launch(Dispatchers.IO) and withContext(Dispatchers.IO)?
launch creates a new concurrent coroutine. withContext switches the current coroutine to a different dispatcher sequentially. This distinction affects concurrency, result access, and code clarity.
// withContext — same coroutine, switches dispatcher, returns result viewModelScope.launch { val user = withContext(Dispatchers.IO) { // switches, waits, returns api.fetchUser() // runs on IO } // back on Main _state.value = user // can use result immediately } // launch(IO) — new coroutine, parallel, no result viewModelScope.launch { launch(Dispatchers.IO) { // new coroutine, runs independently val user = api.fetchUser() // Can't return user to parent — different coroutine! withContext(Dispatchers.Main) { _state.value = user } } // This runs concurrently — doesn't wait for launch{} _state.value = UiState.Loading } // When to use which: // withContext: need the result, sequential, context switch only // launch(IO): fire-and-forget background work, parallel // async(IO): parallel + need result (await it) // Repository pattern — withContext is the right choice suspend fun getUser(id: String): User = withContext(Dispatchers.IO) { dao.getUser(id) // result returned to caller } // Anti-pattern: launch(IO) in repository // suspend fun getUser(id: String): User { // launch(IO) { dao.getUser(id) } // ❌ result lost! // }
withContext — sequential dispatcher switch — withContext(Dispatchers.IO) { ... } suspends the current coroutine, switches to the IO dispatcher, runs the block, then switches back and resumes. The code after withContext sees the result directly as a return value. It's sequential — the coroutine doesn't proceed until the IO block completes. This is the correct pattern for repository functions: suspend fun getUser(): User = withContext(Dispatchers.IO) { api.fetchUser() }. The caller can use the result immediately after the suspend call returns.
launch — fire-and-forget parallel work — launch(Dispatchers.IO) { ... } creates a new child coroutine that runs independently on the IO dispatcher while the parent continues executing immediately. There's no easy way to get a return value from launch — it returns a Job, not the result. Use launch for truly independent side-effect work: logging, analytics, cache warming — things where you don't need the result and failures are acceptable. The anti-pattern is using launch(IO) to get a result: you'd have to use a CompletableDeferred or shared state, which is awkward and error-prone.
async — parallel work with a result — when you need parallel execution AND a result, use async(Dispatchers.IO) { ... }. It returns a Deferred<T> that you can await() to get the value. For parallel API calls: val userDeferred = async { api.getUser() }; val postsDeferred = async { api.getPosts() }; val user = userDeferred.await(); val posts = postsDeferred.await(). Both calls run simultaneously. The decision tree: need a result + sequential? withContext. Need results + parallel? async. No result needed? launch.
Repository functions should always use withContext(IO), never launch(IO). withContext is a suspend function — it's designed for "switch context, do work, return result." launch is for fire-and-forget. Mixing them up causes subtle bugs where results are lost.
46. What are coroutine flow transformations — mapLatest, transformLatest, and their use cases?
mapLatest and transformLatest cancel the previous transformation when a new value arrives — combining mapping with the cancellation behavior of flatMapLatest.
// mapLatest — map + cancel if new value arrives mid-transform userIdFlow .mapLatest { id -> delay(500) // if new id arrives, this delay is cancelled api.fetchUser(id) // and this fetch is cancelled too } .collect { showUser(it) } // vs regular map — never cancels userIdFlow .map { id -> api.fetchUser(id) // all run to completion, even stale ones } // transformLatest — emit multiple + cancel searchQueryFlow .transformLatest { query -> emit(SearchState.Loading) // emit loading immediately delay(300) // debounce val results = api.search(query) emit(SearchState.Success (results)) } .collect { updateUi(it) } // New query cancels previous loading + debounce + search // Equivalences: // mapLatest = flatMapLatest { flowOf(transform(it)) } // transformLatest = flatMapLatest { flow { transform(it) } } // Real-world: live price updates selectedStockFlow .mapLatest { ticker -> priceApi.getPrice(ticker) // cancelled if user selects different stock } .collect { showPrice(it) }
mapLatest — cancel stale transformations — mapLatest applies a suspend transformation to each value, but if a new value arrives before the previous transformation completes, it cancels the previous one and starts fresh. This is the suspend-aware version of map. Use it when the transformation is expensive (fetching related data, heavy computation) and previous results become irrelevant when a new input arrives: userIdFlow.mapLatest { userId -> api.getUserDetails(userId) }. If the user switches profile while the previous fetch is in-flight, the old network call is cancelled and a new one starts immediately.
transformLatest — cancel and emit multiple values — transformLatest is the more powerful variant: its block receives a value and can emit multiple values before the next input arrives, but is cancelled entirely when a new input comes. This is equivalent to flatMapLatest with a manually constructed inner Flow. Use it for complex transformations that produce a stream of intermediate values: showing a loading state, then progress updates, then the final result — all cancellable when a new query arrives.
When to use map vs mapLatest — regular map processes every value in order, even if they pile up. All values are eventually processed. Use map when ordering matters and you can't skip any value (financial transactions, analytics events). Use mapLatest when the most recent value is the only one that matters — the user's current search query, the latest sensor reading, the current user ID. In UI-heavy Android code, mapLatest and flatMapLatest appear far more often than regular map on Flows, because UI state is inherently "latest wins" — nobody cares about a search result for a query the user already changed.
transformLatest is the cleanest way to implement search with loading state: emit Loading immediately (so UI shows spinner), then debounce, then fetch. If user types again mid-fetch, the whole thing cancels and restarts — no stale Loading or results.
47. What is the coroutine Job hierarchy? How do you use joinAll and cancelAndJoin?
Jobs form a tree hierarchy — parent tracks children. joinAll waits for multiple jobs, cancelAndJoin cancels and waits for cleanup to complete.
// Job states: New → Active → Completing → Completed // ↓ // Cancelling → Cancelled val job1 = viewModelScope.launch { doWork() } val job2 = viewModelScope.launch { doMoreWork() } // joinAll — suspend until all jobs complete joinAll(job1, job2) println("Both done") // cancelAndJoin — cancel + wait for cancellation to finish // (important: coroutine does cleanup in finally block) job1.cancelAndJoin() // waits for finally{} to complete println("Cancelled and cleaned up") // vs just cancel() — doesn't wait for cleanup job1.cancel() // returns immediately, cleanup still running! // Job hierarchy — parent waits for children val parentJob = viewModelScope.launch { val child1 = launch { delay(1000); doWork() } val child2 = launch { delay(2000); doMore() } // Parent doesn't complete until both children complete } // parentJob.join() waits ~2 seconds // Job inspection job1.isActive // true while running or waiting job1.isCompleted // true after success or cancel job1.isCancelled // true if cancelled job1.children // sequence of child jobs
Job state machine — a Job transitions through well-defined states: New (created but not started) → Active (running) → Completing (body finished, waiting for children) → Completed (all children done). Cancellation creates a parallel path: Active → Cancelling → Cancelled. Understanding these states matters when you need to check job.isActive, job.isCancelled, or job.isCompleted. A Job in the Completing state is still "alive" — it hasn't completed yet because its children are still running. Wait for job.join() to ensure truly everything is done.
joinAll vs awaitAll — jobs.joinAll() suspends until all listed Jobs complete, regardless of success or failure. It's the Unit-returning parallel wait — you've already collected results elsewhere, you just need to know when all work is done. deferreds.awaitAll() suspends until all Deferred<T> complete and returns a List<T> of all results — or throws if any deferred failed. In practice, awaitAll is more commonly useful because you typically want the results. Use joinAll for fire-and-forget parallel tasks where you need synchronization but not results.
cancel() vs cancelAndJoin() — job.cancel() sends the cancellation signal and returns immediately — the coroutine may still be running its finally blocks and cleanup code. job.cancelAndJoin() sends the signal and suspends until the coroutine fully completes, including all cleanup. Use cancelAndJoin() when you need to ensure cleanup is complete before proceeding — for example, closing a resource before opening a new one. Use cancel() when you don't need to wait for cleanup, but be aware that the coroutine is still technically running for a brief period after the call returns.
cancelAndJoin vs cancel is subtle but important. cancel() returns before the finally block runs. If you start a new operation right after cancel(), the cleanup and new operation race. cancelAndJoin() ensures cleanup is complete before proceeding — use it when order matters.
48. Scenario: Design a download manager that downloads multiple files, shows progress, supports pause and resume, and retries on failure.
A download manager combines all coroutine concepts: Jobs, Channels for progress, pause/resume with StateFlow, retry on failure, and Flow for observable state.
class DownloadManager @Inject constructor( private val api: DownloadApi ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val downloads = ConcurrentHashMap<String, DownloadJob>() fun download(url: String): Flow<DownloadState> { val stateFlow = MutableStateFlow<DownloadState>(DownloadState.Queued) val job = scope.launch { retryWithBackoff(maxRetries = 3) { stateFlow.value = DownloadState.Downloading(0) api.download(url).collect { (bytes, total) -> stateFlow.value = DownloadState.Downloading (bytes * 100 / total) } stateFlow.value = DownloadState.Done } } downloads[url] = DownloadJob(job, stateFlow) return stateFlow.asStateFlow() } fun pause(url: String) { downloads[url]?.job?.cancel() downloads[url]?.state?.value = DownloadState.Paused } fun resume(url: String) { download(url) } fun cancelAll() { scope.coroutineContext[Job]?.cancelChildren() } } sealed class DownloadState { object Queued : DownloadState() data class Downloading(val progress: Int) : DownloadState() object Paused : DownloadState() object Done : DownloadState() }
Scope design with SupervisorJob — the download manager needs its own CoroutineScope with a SupervisorJob: val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO). SupervisorJob ensures one download failing (network error, disk full) doesn't cancel other active downloads. Inject this scope via Hilt as a @Singleton — it lives for the application lifetime, so downloads survive screen changes and continue in the background. Each download runs as a child coroutine: downloadJobs[id] = scope.launch { download(url, destination) }.
Observable progress with StateFlow — track each download's state with a MutableStateFlow<DownloadState> keyed by download ID. DownloadState is a sealed class: Queued, Downloading(progress: Float), Paused, Completed, Failed(error: String). The download coroutine updates this StateFlow as it runs: emit Downloading(bytesRead / totalBytes) on each chunk. The UI observes downloadStates[id] with a collectAsStateWithLifecycle() to show a progress bar. Multiple screens can observe the same download's progress — StateFlow multicasts to all collectors automatically.
Pause, resume, and cancel — pause by cancelling the download Job (cancellation unwinds the coroutine cleanly) and updating state to Paused: downloadJobs[id]?.cancel(); states[id]?.value = Paused. Resume by creating a new coroutine that seeks to the already-downloaded byte offset. Cancel all downloads with scope.coroutineContext[Job]?.cancelChildren() — this cancels all child coroutines without destroying the scope, so new downloads can be started immediately after. Destroying the scope would prevent any future use. Retry failed downloads using the exponential backoff pattern inside the download coroutine: wrap the download loop in retryWhen { cause, attempt -> cause is IOException && attempt < 3 }.
This design shows you understand Job lifecycle management, SupervisorJob for independent failures, and StateFlow for observable progress. The key insight: cancelChildren() cancels all active downloads without destroying the scope — so you can start new downloads afterward.
49. What are the latest coroutine improvements in Kotlin 2024-2025?
Kotlin coroutines in 2024-25 received several improvements: structured concurrency enforcement, coroutineScope builders for parallel decomposition, improved Flow operators, and better integration with Compose via collectAsStateWithLifecycle. The most impactful production improvement is replacing LiveData with StateFlow.
// Kotlin 2.0+ coroutine improvements // awaitAll() -- parallel async with structured error propagation val (user, orders) = coroutineScope { val u = async { api.getUser(id) } val o = async { api.getOrders(id) } awaitAll(u, o) // both run in parallel, either failure cancels both } // Flow.toCollection() -- new terminal operator (Kotlin 1.9+) val items = flow.toList() // collects all emissions into a list // collectAsStateWithLifecycle -- lifecycle-safe Compose collection val state by viewModel.uiState.collectAsStateWithLifecycle() // Stops collecting when the composable leaves the Lifecycle.State.STARTED state
collectAsStateWithLifecycle — Compose lifecycle safety — flow.collectAsStateWithLifecycle() from lifecycle-runtime-compose is the correct way to collect a Flow in Compose. Unlike collectAsState(), it respects the Android lifecycle: collection pauses when the app goes to background (lifecycle below STARTED) and resumes when it returns to foreground. This prevents wasted network calls, battery drain, and memory leaks from flows processing data nobody is currently seeing. Every production app using Compose with Flows should use this over the simpler collectAsState().
StateFlow replacing LiveData — StateFlow is the modern replacement for LiveData and should be the default choice for new Android code. StateFlow is pure Kotlin with no Android framework dependency, works in Kotlin Multiplatform without modification, supports all Flow operators (map, filter, combine, flatMapLatest), is thread-safe, and has a cleaner API. LiveData is bound to Android's lifecycle and has quirks (observing on multiple threads, dispatchingValue, mediator complexity) that StateFlow avoids. Google's own architecture samples have migrated from LiveData to StateFlow, and KMP projects require StateFlow since LiveData doesn't exist in shared Kotlin code.
K2 compiler improvements for coroutines — Kotlin 2.0's K2 compiler (stable since Kotlin 2.0.0) generates more efficient coroutine state machines. The CPS (continuation-passing style) transformation that the compiler applies to suspend functions produces smaller bytecode and has fewer allocations at runtime. Practically, this means faster app startup (less class loading), smaller APK size, and marginally faster coroutine resumption. The improvements are most noticeable in codebases with hundreds of suspend functions. Beyond performance, K2 also provides better error messages for coroutine-related type errors, making it easier to diagnose incorrect suspend function signatures and Flow operator chains.
Mentioning K2 compiler improvements for coroutines shows you track the ecosystem. More impactful: mention that you use -Dkotlinx.coroutines.debug in development and Turbine for all Flow tests. These are the practical, production-quality habits that impress senior interviewers.
50. Scenario: You're reviewing a PR and find this code — what's wrong and how do you fix it?
Code review questions test whether you can identify multiple coroutine anti-patterns — a favourite senior interview technique.
// ❌ PR CODE WITH MULTIPLE BUGS — can you find them? class BadViewModel : ViewModel() { fun loadData() { // Bug 1: GlobalScope.launch(Dispatchers.IO) { val data = api.fetchData() // Bug 2: Thread.sleep(1000) // Bug 3: _state.value = data } } fun search(query: String) { // Bug 4: try { viewModelScope.launch { searchApi.search(query) } } catch (e: Exception) { } // Bug 5: viewModelScope.launch(Dispatchers.IO) { val result = async { processLocally(query) }.await () _results.value = result } } } // ✅ FIXED VERSION: class GoodViewModel : ViewModel() { fun loadData() { viewModelScope.launch { // Fix 1: viewModelScope val data = withContext(Dispatchers.IO) { api.fetchData() } delay(1000) // Fix 2: delay not Thread.sleep _state.value = data // Fix 3: already on Main } } fun search(query: String) { viewModelScope.launch { // Fix 4: try-catch inside coroutine try { searchApi.search(query) } catch (e: Exception) { } } viewModelScope.launch { // Fix 5: withContext instead of async+await val result = withContext(Dispatchers.Default) { processLocally(query) } _results.value = result } } }
Bug 1 — GlobalScope — using GlobalScope.launch in a ViewModel is a critical leak. When the ViewModel is cleared (user navigates away, screen rotation with no observers), viewModelScope is cancelled and all its children stop. But GlobalScope coroutines are never cancelled — they run until process death. If this ViewModel fetches user data, it will keep fetching long after the screen is gone. Fix: replace every GlobalScope.launch with viewModelScope.launch. The ViewModel lifecycle guarantees the coroutine is cancelled exactly when the ViewModel is cleared.
Bug 2 — Thread.sleep() and Bug 4 — try-catch placement — Thread.sleep() inside a coroutine blocks the thread for the entire duration, making it uncancellable and wasting an IO thread that could serve other coroutines. Replace with delay() which suspends cooperatively. The try-catch placed outside a launch { } block catches nothing — exceptions from coroutines propagate through the coroutine's Job hierarchy, not through the call stack where launch was called. To catch exceptions from a launched coroutine, either use a CoroutineExceptionHandler in the launch context, wrap the code inside the launch block in try-catch, or use async { }.await() which rethrows exceptions at the await() call site.
Bug 5 — async{}.await() anti-pattern — async { expensiveWork() }.await() is equivalent to withContext(coroutineContext) { expensiveWork() } but with unnecessary overhead: it creates a Deferred object, allocates a child Job, then immediately suspends to await it. If the intent is to switch dispatchers sequentially, withContext(Dispatchers.IO) { ... } is the correct tool — it's more efficient (no Deferred allocation), more readable, and semantically accurate. The only reason to use async { }.await() is if you specifically need the Deferred for concurrent work, which defeats the sequential purpose. This pattern suggests the author confused async (for parallelism) with withContext (for dispatcher switching).
Code review questions test pattern recognition. The 5 bugs: GlobalScope (leak), Thread.sleep (blocking), try-catch placement (never works), and async-await when withContext suffices (needless overhead). Finding all 5 and explaining fixes demonstrates genuine expertise.