Introduction
Kotlin Flow's catch operator intercepts exceptions flowing downstream from upstream operators. However, catch only catches exceptions from operators above it in the chain — exceptions from operators below catch (downstream) or from the terminal collector (e.g., collect) will bypass it entirely. Additionally, catch terminates the flow when an exception is caught unless you emit a fallback value. Understanding operator ordering is critical for building resilient flow pipelines.
Symptoms
catchblock never executes despite upstream throwing exceptionsCancellationExceptionnot caught bycatch- Exception from
maportransformaftercatchcrashes the app - Flow terminates silently after exception with no fallback
catchcatches some exceptions but not others from the same source
Error output:
``
kotlinx.coroutines.flow.FlowKt__CollectKt$collect$1.invokeSuspend
IllegalStateException: Network error
at com.example.repository.UserRepository$getUsers$1.invokeSuspend
Common Causes
catchplaced after the operator that throws (wrong ordering)catchdoes not catchCancellationExceptionby design- Exception thrown in terminal
collectlambda, not in flow catchcatches exception but does not emit, terminating flow- Multiple
catchblocks with overlapping exception types
Step-by-Step Fix
- 1.Place catch BEFORE the operators that may throw:
- 2.```kotlin
- 3.// WRONG - catch AFTER map, map's exceptions not caught
- 4.flowOf("a", "b", "c")
- 5..map { processItem(it) } // If this throws, catch won't catch it!
- 6..catch { e -> emit("fallback") }
- 7..collect { println(it) }
// CORRECT - catch BEFORE map flowOf("a", "b", "c") .catch { e -> emit("fallback") } // Catches from flowOf and downstream ops .map { processItem(it) } .collect { println(it) }
// For specific error handling per operation flowOf("a", "b", "c") .map { try { processItem(it) } catch (e: IOException) { "network error for $it" } } .catch { e -> // Only catches from upstream (flowOf) emit("global fallback: ${e.message}") } .collect { println(it) } ```
- 1.Use retry for automatic retry on transient failures:
- 2.```kotlin
- 3.// Kotlin Coroutines 1.6.0+
- 4.val users = userRepository.getUsersFlow()
- 5..map { parseUser(it) }
- 6..retry(
- 7.retries = 3,
- 8.delayMillis = 1000L,
- 9.test = { cause -> cause is IOException } // Only retry IO errors
- 10.)
- 11..catch { e ->
- 12.// After all retries exhausted
- 13.emit(emptyList<User>())
- 14.}
- 15..flowOn(Dispatchers.IO)
// Custom retry with exponential backoff fun <T> Flow<T>.retryWithBackoff( maxRetries: Int = 3, initialDelay: Long = 1000L, maxDelay: Long = 30000L, factor: Double = 2.0, test: (Throwable) -> Boolean = { true } ): Flow<T> { var currentDelay = initialDelay return retry(maxRetries) { cause -> if (test(cause)) { delay(currentDelay) currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay) true } else { false } } } ```
- 1.Use onCompletion for terminal event handling (does not catch):
- 2.```kotlin
- 3.// onCompletion runs when flow completes (success, error, or cancellation)
- 4.// It does NOT catch exceptions - they still propagate downstream
- 5.flowOf("a", "b", "c")
- 6..map { processItem(it) }
- 7..onCompletion { cause ->
- 8.when {
- 9.cause == null -> Log.d("Flow", "Completed successfully")
- 10.cause is CancellationException -> Log.d("Flow", "Cancelled")
- 11.else -> Log.e("Flow", "Failed: ${cause.message}")
- 12.}
- 13.}
- 14..catch { e ->
- 15.emit("fallback") // This catches and recovers
- 16.}
- 17..collect { println(it) }
// onEach for per-item error handling flowOf("url1", "url2", "url3") .map { url -> try { Result.success(fetchData(url)) } catch (e: Exception) { Result.failure<List<String>>(e) } } .collect { result -> result.onSuccess { data -> updateUI(data) }.onFailure { error -> showPartialError(error) } } ```
- 1.Catch exceptions in the collector:
- 2.```kotlin
- 3.// Exceptions in collect are NOT caught by flow's catch operator
- 4.flowOf(1, 2, 3)
- 5..catch { e -> emit(-1) }
- 6..collect { value ->
- 7.// If this throws, catch above does NOT catch it
- 8.processValue(value) // May throw!
- 9.}
// Solution: wrap collect body flowOf(1, 2, 3) .catch { e -> emit(-1) } .collect { value -> try { processValue(value) } catch (e: Exception) { Log.e("Collector", "Failed to process $value: ${e.message}") } }
// Or use runningCatching flowOf(1, 2, 3) .map { runCatching { processValue(it) } } .collect { result -> result.onSuccess { /* handle success */ } result.onFailure { /* handle error */ } } ```
Prevention
- Always place
catchbefore the operators whose exceptions you want to handle - Use
retryfor transient errors (network, timeouts) instead of manual catch+re-emit - Use
onCompletionfor logging and cleanup, not for error recovery - Wrap collector body in try-catch for exceptions in downstream processing
- Use
Result<T>wrapper inmapfor per-item error handling without flow termination - Test flow pipelines with both successful and failing upstream sources