skip to content

Channel-Backed Flows

How channels and callback APIs become Flows: channelFlow for concurrent emission and callbackFlow for listener-based sources. These are the builders you need when the plain flow builder's single-context rule gets in the way.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

explore

questions

15

What is callbackFlow and when would you reach for it instead of the plain flow { } builder?

level: juniorimportance: must knowfreq 70%

answer

  1. register listener -> trySend -> awaitClose to unregister
  2. bridges callback/listener API into a Flow
  3. trySend is non-suspending, safe from foreign threads
  4. awaitClose is mandatory or it throws
  5. use flow { } when you emit yourself

basics

~10 s

callbackFlow turns an old-style callback or listener API into a Flow. You use it when values arrive through a callback you register, not from suspending code you call directly.

solid answer

~40 s

callbackFlow { } is a Flow builder for bridging callback-based or listener-based APIs into Flow. Inside the block you register a listener and push each event with trySend (it returns a ChannelResult, never suspends to the point of blocking the callback thread), then call awaitClose { } at the end to suspend until the flow is cancelled and unregister the listener there. Unlike flow { }, the producer block runs concurrently with collection and is backed by a channel, so a foreign thread invoking the callback can safely emit. Use plain flow { } when you can simply call emit yourself from suspending/sequential code; use callbackFlow when emissions originate asynchronously from a callback you do not control.

code

kotlin · 5 lines
kotlin
fun connectionState(api: SocketApi): Flow<State> = callbackFlow {
    val cb = SocketApi.Callback { state -> trySend(state) }
    api.addCallback(cb)
    awaitClose { api.removeCallback(cb) }
}

go deeper

for a junior

Knows callbackFlow adapts a listener API and that you must unregister in awaitClose.

for a middle

Explains the channel backing, why flow { } cannot emit from a callback thread, and trySend vs emit.

for a senior

Discusses cold semantics, close vs cancel, and how downstream cancellation drives cleanup deterministically.

for a principal

Frames callbackFlow within a resource-lifecycle/backpressure strategy and standardizes adapter patterns across the codebase.

## The problem callbackFlow solves Many Kotlin/Java/Android APIs are **callback-based**: you register a listener object, and the library calls your method whenever something happens (a location update, a websocket message, a database change). A `Flow` is a cold asynchronous stream of values. `callbackFlow { }` is the **bridge** that adapts one into the other. The plain `flow { }` builder is *sequential and constrained*: emissions must come from the same coroutine via `emit`, and you may not call `emit` from a different thread/coroutine (it throws an `IllegalStateException` for emission context violations). Callbacks fire on **foreign threads** at unpredictable times, so `flow { }` does not fit. ## How callbackFlow works ```kotlin fun locationUpdates(client: LocationClient): Flow<Location> = callbackFlow { val listener = object : LocationListener { override fun onLocation(loc: Location) { trySend(loc) // push into the channel; safe from any thread } } client.register(listener) awaitClose { client.unregister(listener) } // cleanup on cancel/close } ``` - **Backed by a channel** — `callbackFlow` is a `channelFlow` (a `ProducerScope<T>`) whose block can send from any thread. - **`trySend(value)`** — non-suspending offer that returns a `ChannelResult`; the right tool inside a synchronous callback because the callback signature usually cannot suspend. - **`awaitClose { }`** — **mandatory**. It suspends the builder block until the flow is cancelled or `close()`d, then runs its lambda for teardown (unregister the listener). Omitting it throws `IllegalStateException` at runtime because the block would return immediately and the channel would close while the listener is still registered. ## Terminating the flow - **`close(cause?)`** — completes the flow normally (or with a cause); collectors stop. Use it when the source signals "done." - **`cancel(cause)`** — cancels with a `CancellationException`. - **Downstream cancellation** — if the collector's scope is cancelled, `awaitClose` resumes and your cleanup runs. ## Key APIs/keywords `callbackFlow`, `ProducerScope`, `trySend`, `send` (suspending), `awaitClose`, `close`, `cancel`, `ChannelResult`, `Flow`.

  • Why can't you just use flow { } and call emit from inside the callback?
    emit must be called from the flow's own coroutine context; calling it from a foreign callback thread violates the emission-context check and throws IllegalStateException. callbackFlow is channel-backed so cross-thread sends are allowed.
  • Is callbackFlow cold or hot?
    Cold: the producer block (listener registration) runs fresh on each collection and stops when collection stops.

callbackFlow is an answering machine: it records messages left by callers (callbacks) whenever they call, and you pick them up later by collecting.

saying these in an interview costs you the question

  • Says callbacks can emit directly with emit() in flow { }
  • Forgets awaitClose entirely
  • Thinks callbackFlow is hot/shared by default
  • Confuses it with StateFlow/SharedFlow
  • Says trySend suspends

context

open as a page

What is channelFlow { } and how does it differ from the plain flow { } builder?

level: juniorimportance: must knowfreq 60%

basics

~10 s

channelFlow builds a cold Flow but lets you emit values from several coroutines at once using send(). The plain flow builder only lets you emit from one place, sequentially.

open as a page

What is the core difference between a Channel and a Flow in Kotlin coroutines, and what does it mean to call a Channel 'hot' and a Flow 'cold'?

level: juniorimportance: must knowfreq 70%

basics

~20 s

A Channel is like a queue that passes items between coroutines; each item is taken by one receiver. A Flow is a recipe that re-runs from the start for every collector. Channel is hot (always live); Flow is cold (starts when collected).

open as a page

What is the role of awaitClose { } in callbackFlow, and what happens if you omit it?

level: middleimportance: must knowfreq 65%

basics

~10 s

awaitClose keeps the flow alive until it is cancelled or closed, then runs cleanup like unregistering the listener. If you leave it out, the flow ends immediately and Kotlin throws an error.

open as a page

Why does emitting from a launched coroutine inside flow { } throw an exception, and how does channelFlow solve it?

level: middleimportance: must knowfreq 50%

basics

~20 s

flow { } requires that all emit() calls happen in the same coroutine, so emitting from a new coroutine breaks that rule and throws. channelFlow uses a channel and a thread-safe send(), so any coroutine can produce values.

open as a page

How do you terminate a callbackFlow from inside, and how does close() differ from cancel()?

level: middleimportance: should knowfreq 45%

basics

~10 s

Call close() when the source is finished normally; collectors stop and cleanup runs. Call cancel() to stop with an error/cancellation. Both resume awaitClose so your listener gets unregistered.

open as a page

You expose a producer's output as receiveAsFlow() and two coroutines collect it concurrently. Each collector expects to see every element. Why is this broken, and how do you fix it?

level: middleimportance: should knowfreq 45%

basics

~20 s

A Channel gives each item to only one receiver, so the two collectors split the elements instead of both seeing them all. To give every collector the full stream, use a SharedFlow (or shareIn) instead of a raw channel.

open as a page

What is the difference between Channel.receiveAsFlow() and Channel.consumeAsFlow()? When would you pick each?

level: middleimportance: should knowfreq 55%

basics

~20 s

Both turn a Channel into a Flow. consumeAsFlow can be collected only once and closes the channel afterward. receiveAsFlow can be collected by several collectors that share/compete for elements and does not close the channel.

open as a page

Inside a callbackFlow, when do you use trySend versus send, and how do you handle backpressure?

level: seniorimportance: should knowfreq 55%

basics

~10 s

Use trySend in a normal (non-suspending) callback because it never blocks; it just succeeds, fails, or signals closed. Use send only from suspending code that can wait. Control overflow with a buffer.

open as a page

How does buffering and backpressure work in channelFlow, and what is the default channel capacity?

level: seniorimportance: should knowfreq 38%

basics

~10 s

By default the backing channel has no buffer (rendezvous), so send() suspends until the collector takes the value, giving natural backpressure. Adding buffer() or a capacity lets producers run ahead.

open as a page

How does structured concurrency and cancellation behave for coroutines launched inside channelFlow?

level: seniorimportance: should knowfreq 32%

basics

~20 s

Coroutines you launch inside channelFlow are children of the builder's scope. The flow completes only when they all finish, and cancelling the collector cancels them too. An exception in any child cancels the whole flow.

open as a page

A teammate is using a raw Channel to back a public API stream that several callers collect. What questions do you ask to decide whether a Channel, a cold Flow, or a SharedFlow/StateFlow is the right primitive?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Ask: should every caller get every value or should values be split among them? Does the stream start fresh per caller or run once and broadcast? Is there a current value to read instantly? Those answers point to cold Flow, SharedFlow, StateFlow, or Channel.

open as a page

Explain the ownership and cleanup differences when you expose a Channel through consumeAsFlow() versus receiveAsFlow(), and how each affects resource leaks and cancellation propagation.

level: seniorimportance: nice to knowfreq 25%

basics

~20 s

consumeAsFlow makes the Flow own the channel: when collection stops, the channel is cancelled, so resources are cleaned up automatically. receiveAsFlow does not own it, so you must close the channel yourself or collectors hang and resources leak.

open as a page

What are the common leak and correctness pitfalls when wrapping a callback API with callbackFlow, and how do you make the adapter robust?

level: principalimportance: nice to knowfreq 35%

basics

~10 s

The big risks are forgetting to unregister the listener, dropping events under load, and emitting after the flow closed. Fix them by always unregistering in awaitClose, choosing a buffer policy, and checking trySend results.

open as a page

Given a fan-in scenario merging several concurrent sources, justify choosing channelFlow over flow { } and over a raw Channel.

level: principalimportance: nice to knowfreq 22%

basics

~10 s

channelFlow lets several sources send into one cold, reusable Flow with automatic cleanup and backpressure. flow { } can't emit concurrently, and a raw Channel is hot, single-use, and needs manual lifecycle management.

open as a page