skip to content

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%

answer

  1. always unregister in awaitClose (leak)
  2. pick a buffer/overflow policy (silent drops)
  3. trySend after close returns closed, not throw
  4. cold: one listener per collector -> shareIn/stateIn to fan out
  5. close(error) propagates; callback runs on source thread

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.

solid answer

~40 s

Robust callbackFlow adapters must: (1) always unregister the listener in awaitClose so external cancellation can't leak it; (2) pick an explicit backpressure policy via buffer()/conflate()/onBufferOverflow because the default channel can drop or fail trySend under load; (3) not emit after termination — trySend on a closed channel returns a closed ChannelResult rather than throwing, so silent loss is possible; (4) ensure exactly-once registration inside the block (it runs per collection, so the flow stays cold and each collector gets its own listener); (5) propagate source errors with close(throwable). Operators like flowOn change the dispatcher downstream but the callback still fires on the source's thread. For multiple subscribers sharing one listener, expose the callbackFlow then apply shareIn/stateIn rather than registering N listeners.

go deeper

for a junior

Recognizes that forgetting to unregister the listener leaks resources.

for a middle

Adds buffer/overflow awareness and knows the flow is cold (per-collector listener).

for a senior

Handles emit-after-close, error propagation via close(error), and dispatcher nuances.

for a principal

Establishes adapter standards: leak-safe teardown, explicit backpressure contracts, shareIn/stateIn fan-out, and consistent error semantics across the codebase.

## Pitfall 1 — listener leaks The most common bug is registering a listener but failing to unregister it on **external cancellation**. Always do teardown in `awaitClose`: ```kotlin callbackFlow { val l = Listener { trySend(it) } source.add(l) awaitClose { source.remove(l) } // runs on close AND downstream cancel } ``` Without this you leak the listener (and often the captured scope) whenever a collector's lifecycle ends. ## Pitfall 2 — silent event loss The default channel is rendezvous-like; a bursty source overruns a slow collector and `trySend` **fails silently** if you ignore its `ChannelResult`. Choose deliberately: - `buffer(capacity)` for headroom, - `conflate()` / `BufferOverflow.DROP_OLDEST` for latest-wins state, - inspect `trySend(...).isFailure` to record drops. ## Pitfall 3 — emit-after-close A callback may fire *after* you've `close()`d or after downstream cancelled. `trySend` returns a **closed** `ChannelResult` instead of throwing, so the value is dropped — fine, but don't do side effects assuming delivery. ## Pitfall 4 — coldness and per-collector listeners The block runs **once per collection**, so each collector registers its own listener (cold semantics). If the source only supports a single registration, or registration is expensive, **share** instead of registering N times: ```kotlin val shared = source.asFlow().shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1) ``` `shareIn`/`stateIn` multiplex one upstream subscription across many collectors. ## Pitfall 5 — error & dispatcher handling - Propagate source failures with `close(throwable)` so collectors can `catch`. - The **callback still runs on the source's thread**; `flowOn`/dispatchers affect the producer coroutine and downstream, not where the library invokes your callback. Keep callback bodies minimal (just `trySend`). ## Robustness checklist - awaitClose unregisters (always). - Explicit buffer/overflow policy. - Handle/ignore-on-purpose closed trySend results. - Cold by design; use shareIn/stateIn for fan-out. - close(error) for source failures; keep callback bodies tiny. ## Key APIs `callbackFlow`, `awaitClose`, `trySend`/`ChannelResult`, `buffer`, `conflate`, `BufferOverflow`, `shareIn`, `stateIn`, `SharingStarted`, `flowOn`, `close`.

  • Why might flowOn not change where your callback executes?
    flowOn changes the dispatcher of the producer coroutine and upstream emission, but the third-party library invokes your registered callback on its own thread; you only trySend there.
  • How do you avoid registering N listeners for N collectors?
    Apply shareIn or stateIn over the callbackFlow with a sharing strategy (e.g., WhileSubscribed) so one upstream registration is multiplexed.

saying these in an interview costs you the question

  • Only unregisters on normal close, leaking on cancellation
  • Ignores trySend results and assumes no loss
  • Expects trySend to throw after close
  • Assumes the flow is hot/shared by default
  • Thinks flowOn moves where the library invokes the callback

context