What are the subtle pitfalls of onStart and onCompletion: emitting after failure, exception transparency violations, and ordering with flowOn and catch?
answer
- emit in onCompletion only if cause == null
- onCompletion != error handler
- flowOn doesn't move onStart/onCompletion
- onStart seed passes through downstream ops
- never swallow CancellationException
basics
~10 sDon'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 sKey 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 linesupstream
.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 contextgo deeper
Knows not to treat onCompletion as a catch and that it runs for all endings.
Avoids logging cancellation as error and guards emits with cause == null.
Reasons about catch ordering and that flowOn does not move the hooks' context.
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