skip to content

What are the subtle pitfalls of onStart and onCompletion: emitting after failure, exception transparency violations, and ordering with flowOn and catch?

level: principalimportance: nice to knowfreq 25%

answer

  1. emit in onCompletion only if cause == null
  2. onCompletion != error handler
  3. flowOn doesn't move onStart/onCompletion
  4. onStart seed passes through downstream ops
  5. never swallow CancellationException

basics

~10 s

Don't emit from onCompletion after a failure, don't treat onCompletion as a place to swallow errors, and remember catch and flowOn ordering changes what onCompletion sees and which thread it runs on.

solid answer

~40 s

Key pitfalls: (1) Emitting from onCompletion when cause is non-null is dangerous because the exception still propagates afterward, so guard with if (cause == null). (2) onCompletion is not an error handler; using it to 'recover' silently breaks exception transparency and may hide cancellation. (3) Operator ordering: catch placed upstream of onCompletion converts failures to normal completion, so onCompletion sees cause == null; placed downstream, onCompletion sees the raw failure. (4) flowOn only affects upstream context, so it does not move onStart/onCompletion off the collector context, which can surprise people expecting a background thread. (5) onStart emitting a seed value participates in backpressure and downstream operators, so a seed can be transformed/filtered by later operators. (6) CancellationException as cause must not be logged as an error or rethrown wrapped, or you corrupt structured-concurrency cancellation.

code

kotlin · 9 lines
kotlin
upstream
    .onStart { emit(0) }                 // seed; flows downstream
    .map { it * 2 }                      // seed becomes 0 here too
    .onCompletion { cause ->
        if (cause is CancellationException) return@onCompletion // hygiene
        if (cause == null) emit(-1)      // safe fallback only on clean end
    }
    .flowOn(Dispatchers.IO)              // affects ONLY ops above this line
    .collect { println(it) }            // onStart/onCompletion ran in collector context

go deeper

for a junior

Knows not to treat onCompletion as a catch and that it runs for all endings.

for a middle

Avoids logging cancellation as error and guards emits with cause == null.

for a senior

Reasons about catch ordering and that flowOn does not move the hooks' context.

for a principal

Synthesizes transparency, cancellation hygiene, seed-emission backpressure, and context boundaries into safe reusable lifecycle operators.

## Pitfall 1 — Emitting from onCompletion after a failure `onCompletion`'s receiver is a `FlowCollector`, so you *can* `emit()`. But if `cause != null` the exception still propagates after your block, so a fallback emission is fragile and may race with termination. ```kotlin .onCompletion { cause -> if (cause == null) emit(sentinelValue) // only emit on clean completion } ``` ## Pitfall 2 — Treating onCompletion as error handling It only observes; it always rethrows. Trying to 'handle' there (e.g. returning normally and expecting suppression) does nothing — use `catch`. Silently swallowing via a try/catch around emit elsewhere violates **exception transparency** (operators must let downstream exceptions propagate and only `catch` upstream ones). ## Pitfall 3 — Ordering with catch ```kotlin flow { throw IOException() } .catch { /* handled */ } // failure -> normal completion .onCompletion { c -> assert(c == null) } // sees null flow { throw IOException() } .onCompletion { c -> assert(c is IOException) } // sees raw failure .catch { /* handled */ } ``` Place `onCompletion` where it must observe either the raw cause or the post-recovery outcome — decide intentionally. ## Pitfall 4 — flowOn does not move onStart/onCompletion `flowOn(dispatcher)` changes the context of operators **upstream** of it. `onStart`/`onCompletion` (and `catch`) run in the **collector's** context. So: ```kotlin upstream .onStart { /* collector context */ } .flowOn(Dispatchers.IO) // affects ONLY upstream of this line .collect { /* collector context */ } ``` Expecting onStart to run on IO here is wrong — it sits downstream of flowOn. ## Pitfall 5 — onStart seed values are part of the stream A value emitted in `onStart` flows through any **downstream** operators (`map`, `filter`, buffering, backpressure). So `onStart { emit(0) }.filter { it > 0 }` drops the seed. Place seed emission with awareness of later operators. ## Pitfall 6 — Cancellation hygiene The `cause` may be a `CancellationException`. Never log it as a failure, never wrap-and-rethrow it as a different type, and never swallow it — doing so corrupts structured concurrency, leaking coroutines or hanging cancellation. Special-case it explicitly. ## Pitfall 7 — Idempotency and exceptions in the hooks themselves If your `onCompletion` block itself throws, that exception can mask the original `cause`. Keep lifecycle blocks cheap and non-throwing (wrap risky cleanup defensively). ## Summary table | Concern | Right move | |---|---| | Emit on completion | only when `cause == null` | | Recover from error | use `catch`, not onCompletion | | Observe raw failure | put onCompletion before catch | | Background context | flowOn upstream; hooks stay on collector | | Cancellation | detect `is CancellationException`, don't treat as error |

  • Why can emitting a fallback in onCompletion be unsafe on failure?
    Because the non-null cause still propagates after the block, so the emission races with termination and the consumer still receives the exception.
  • If you add flowOn after onStart, does onStart run on that dispatcher?
    No. flowOn only changes the context of operators upstream of it; onStart sits downstream and runs in the collector context.
  • What happens if your onCompletion block throws while cause is already non-null?
    The new exception can mask or replace the original cause, so keep lifecycle blocks cheap and defensively non-throwing.

saying these in an interview costs you the question

  • Emitting fallbacks in onCompletion regardless of cause
  • Using onCompletion to swallow/recover from errors
  • Believing flowOn shifts onStart/onCompletion context
  • Logging or rethrowing CancellationException as an error
  • Forgetting onStart seed values pass through downstream operators

context