Channels & Channel-Backed Flows Interview Questions
KOTLIN › Flow
When would you reach for a Channel rather than a SharedFlow, and what do you give up?
What a strong answer covers: The deciding question is whether the consumers should divide the work or each see all of it. A channel delivers every element to exactly one receiver, so four coroutines reading from one channel form a load-balanced work queue where a worker finishing early simply takes the next item. No dispatcher arrangement gives you that, because dispatchers distribute coroutines rather than work items. A SharedFlow broadcasts, so every collector sees every value. The second reason is delivery guarantees for one-off events. A SharedFlow with replay 0 drops an emission when nobody is subscribed, which on Android means an event fired while the screen is backgrounded is simply gone. Raising replay to 1 fixes the loss and creates re-delivery on rotation instead, so the snackbar shows twice. A channel buffers, so nothing is lost, and delivers exactly once, so nothing is replayed. What you give up is multicast. If a second collector appears, the two split the events rather than both receiving them, which is a nasty bug because it looks like events are randomly going missing. So my rule is Channel plus receiveAsFlow for one-off events consumed by a single screen, SharedFlow when several independent listeners genuinely each need to see everything, and StateFlow for state.
Explain the difference between consumeAsFlow and receiveAsFlow, and why it matters on Android.
What a strong answer covers: consumeAsFlow takes ownership of the channel. It can be collected exactly once, a second collection throws IllegalStateException, and when that collection ends, whether normally or through cancellation, it cancels the underlying channel. receiveAsFlow does not take ownership: it can be collected more than once, though the collectors share the channel rather than each getting everything, and it leaves the channel alone when collection ends. On Android that difference is the difference between a working event bus and a silently broken one. The standard pattern collects with repeatOnLifecycle, which cancels the collecting coroutine every time the screen drops below STARTED and starts it again on return. With consumeAsFlow, the first time the user backgrounds the app the collection ends, the channel is cancelled, and from then on the ViewModel can send events into a dead channel forever. The screen stops receiving events with no exception and no log line, so it presents as an intermittent bug that only reproduces if you background the app first. With receiveAsFlow the channel survives, the new collection picks up where the old one left off, and buffered events emitted while backgrounded are delivered on return, which is usually exactly what you want.
How would you build a bounded worker pool that processes items from a queue using coroutines?
What a strong answer covers: Channels give you this almost for free, because fan-out is built into the delivery semantics. I would create a channel for the work items, then launch N worker coroutines that each iterate it with a for loop. Each element is delivered to exactly one receiver, so the workers divide the queue automatically and a worker that finishes early takes the next item without any explicit scheduling. Sizing N is the design decision: CPU-bound processing wants roughly the core count, blocking IO wants more. I would produce the items with the produce builder where possible, since it returns a ReceiveChannel and closes it when the block finishes, which removes the most common bug in this shape, which is forgetting to close and leaving every worker loop waiting forever. If several producers feed one channel, closing has to happen exactly once from a coroutine that has joined all of them, because closing inside each producer means the first to finish cuts off the others. I would wrap the whole thing in a coroutineScope so the structured concurrency rules apply: cancelling the caller cancels the producers and the workers together, and the function does not return until they have all finished. Worth mentioning the alternative: if the work is a simple parallelism cap rather than a queue, Dispatchers.IO.limitedParallelism(n) or a coroutine Semaphore is simpler, because you do not need the channel at all.
What is the trap with trySend, and when is it nevertheless the right call?
What a strong answer covers: The trap is that it fails silently. Unlike send, trySend never suspends and never throws: it returns a ChannelResult, and if the channel is full or closed the value is simply not delivered. If you ignore the result, and almost everyone does, values disappear with nothing in the logs to explain it. The specific case that bites is a callbackFlow wrapper written with the default channel capacity, which is RENDEZVOUS, meaning zero buffer. On a rendezvous channel trySend only succeeds if a receiver is already suspended waiting at that exact moment, so in practice almost every send fails and the flow appears to emit nothing. The fix is to give the channel a buffer, callbackFlow(Channel.BUFFERED) or an explicit capacity with a BufferOverflow policy, and to decide deliberately whether dropping under pressure is acceptable. It is the right call when the producer genuinely cannot suspend, which is exactly the callbackFlow case: a location callback or a click listener is called by the framework on its own thread and has no coroutine to suspend. That is the whole reason trySend exists. Elsewhere, inside a coroutine, send is the correct choice because suspending is how backpressure works and silently dropping values is rarely what you meant.