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

  • catch block never executes despite upstream throwing exceptions
  • CancellationException not caught by catch
  • Exception from map or transform after catch crashes the app
  • Flow terminates silently after exception with no fallback
  • catch catches 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

  • catch placed after the operator that throws (wrong ordering)
  • catch does not catch CancellationException by design
  • Exception thrown in terminal collect lambda, not in flow
  • catch catches exception but does not emit, terminating flow
  • Multiple catch blocks with overlapping exception types

Step-by-Step Fix

  1. 1.Place catch BEFORE the operators that may throw:
  2. 2.```kotlin
  3. 3.// WRONG - catch AFTER map, map's exceptions not caught
  4. 4.flowOf("a", "b", "c")
  5. 5..map { processItem(it) } // If this throws, catch won't catch it!
  6. 6..catch { e -> emit("fallback") }
  7. 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. 1.Use retry for automatic retry on transient failures:
  2. 2.```kotlin
  3. 3.// Kotlin Coroutines 1.6.0+
  4. 4.val users = userRepository.getUsersFlow()
  5. 5..map { parseUser(it) }
  6. 6..retry(
  7. 7.retries = 3,
  8. 8.delayMillis = 1000L,
  9. 9.test = { cause -> cause is IOException } // Only retry IO errors
  10. 10.)
  11. 11..catch { e ->
  12. 12.// After all retries exhausted
  13. 13.emit(emptyList<User>())
  14. 14.}
  15. 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. 1.Use onCompletion for terminal event handling (does not catch):
  2. 2.```kotlin
  3. 3.// onCompletion runs when flow completes (success, error, or cancellation)
  4. 4.// It does NOT catch exceptions - they still propagate downstream
  5. 5.flowOf("a", "b", "c")
  6. 6..map { processItem(it) }
  7. 7..onCompletion { cause ->
  8. 8.when {
  9. 9.cause == null -> Log.d("Flow", "Completed successfully")
  10. 10.cause is CancellationException -> Log.d("Flow", "Cancelled")
  11. 11.else -> Log.e("Flow", "Failed: ${cause.message}")
  12. 12.}
  13. 13.}
  14. 14..catch { e ->
  15. 15.emit("fallback") // This catches and recovers
  16. 16.}
  17. 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. 1.Catch exceptions in the collector:
  2. 2.```kotlin
  3. 3.// Exceptions in collect are NOT caught by flow's catch operator
  4. 4.flowOf(1, 2, 3)
  5. 5..catch { e -> emit(-1) }
  6. 6..collect { value ->
  7. 7.// If this throws, catch above does NOT catch it
  8. 8.processValue(value) // May throw!
  9. 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 catch before the operators whose exceptions you want to handle
  • Use retry for transient errors (network, timeouts) instead of manual catch+re-emit
  • Use onCompletion for logging and cleanup, not for error recovery
  • Wrap collector body in try-catch for exceptions in downstream processing
  • Use Result<T> wrapper in map for per-item error handling without flow termination
  • Test flow pipelines with both successful and failing upstream sources