Droid Manual // Android Knowledge Base

Droid Manual

Coroutines vs RxJava

October 29, 2026 · 7 min read

Introduction

Android developers spent years building reactive pipelines with RxJava, and many legacy codebases still run on Observables and Subjects. Google introduced Kotlin coroutines in 2019 and made them the default for async work on Android. Over time, the community converged on Flow as the reactive stream counterpart to RxJava's Observable type. This article compares both paradigms side by side: the mental model, concurrency primitives, error handling, backpressure semantics, and migration strategies. By the end you should be able to decide when to adopt coroutines, when to keep RxJava, and how to move existing code safely.

Mental Model and Concurrency Primitives

RxJava is built around the Observer pattern: an Observable emits items, and a Subscriber consumes them. Everything flows through operators chained on a Scheduler. Coroutines take a different approach — they are lightweight threads that suspend and resume without blocking OS threads. The key primitive is the CoroutineScope, which ties a group of coroutines to a lifecycle. When the scope is cancelled, every child coroutine stops automatically. This makes lifecycle-awareness trivial compared to unsubscribing Observables by hand.

import kotlinx.coroutines.*

class CoroutineExample {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

    fun startLoading() {
        scope.launch {
            try {
                val data = fetchFromNetwork()
                updateUi(data)
            } catch (e: Exception) {
                showError(e.message)
            }
        }
    }

    fun cancelAll() {
        scope.cancel()
    }

    private suspend fun fetchFromNetwork(): String = withContext(Dispatchers.IO) {
        delay(1000L)
        "loaded"
    }

    private fun updateUi(data: String) {}
    private fun showError(message: String?) {}
}

This example shows a CoroutineScope bound to the Main dispatcher. The launch block runs on the main thread but delegates the expensive fetchFromNetwork call to Dispatchers.IO via withContext. When cancelAll is called, the running coroutine is interrupted and the network call never proceeds. Unlike RxJava, where you must explicitly dispose or unsubscribe, the scope owns the cancellation boundary.

Error Handling

Error handling is one area where coroutines feel more natural to Kotlin developers. An exception inside a suspend function propagates like any regular throw, and try/catch works exactly as expected. In RxJava, errors are emitted as onError events and must be intercepted with doOnError, onErrorReturn, or a terminal subscribe callback. Both approaches can be correct, but coroutines avoid the "callback hell" of nested error handlers. If you prefer declarative error mapping, catch operators exist on Flows as well.

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import java.io.IOException

fun fetchDataFlow(): Flow<String> = flow {
    emit("starting")
    val result = withContext(Dispatchers.IO) {
        if (System.currentTimeMillis() % 2 == 0L) throw IOException("network failure")
        "success"
    }
    emit(result)
}.onStart { emit("initialized") }
    .catch { emit("error handled: ${it.message}") }
    .flowOn(Dispatchers.IO)

The flow emits three values: "initialized", then either "success" or "error handled: network failure". The catch block intercepts the IOException and replaces it with a recovery emission instead of terminating the stream. flowOn shifts the upstream execution to Dispatchers.IO, while downstream operators like collect still run on whatever dispatcher the caller uses. RxJava achieves similar behavior with onErrorResumeNext, but the coroutine version reads like imperative code.

Backpressure Semantics

Backpressure describes what happens when a producer emits faster than a consumer can process. RxJava Observables handle this automatically with a bounded buffer, and operators like switchOnNext manage rapid item turnover. Coroutines and Flow use a different model: a suspending emit blocks the producer until the consumer is ready. This is called cold backpressure because the backpressure signal is the suspension point itself. The trade-off is simplicity — you do not need to configure buffer sizes or choose between eager and lazy backpressure strategies. The cost is that a slow collector can throttle the entire pipeline.

// build.gradle (app)
dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7'
}

This Gradle snippet adds the coroutines Android library and the lifecycle KTX runtime. No additional backpressure configuration is required — the kotlinx-coroutines-android artifact provides the Android Dispatcher that integrates with the Looper. If your project still uses RxJava, the equivalent dependency would be io.reactivex.rxjava3:rxjava:3.x, but it brings its own scheduler and buffer management complexity.

Reactive Streams: Flow vs Observable

Flow is the Kotlin answer to RxJava's Observable, but it is not a drop-in replacement. Flow is strictly sequential, cancels cleanly, and supports structured concurrency out of the box. An Observable can emit from any thread, completes or errors asynchronously, and requires manual resource cleanup. Both support operators like map, filter, and debounce, but the naming and semantics differ. For example, Flow's transform operator is analogous to RxJava's flatMap when dealing with suspended functions, while Flow's stateIn replaces RxJava's share + replay patterns. Migrating one-to-one is possible, but the idioms shift toward coroutine-style thinking.

import kotlinx.coroutines.flow.*

fun numberFlow(): Flow<Int> = flow {
    for (i in 1..10) {
        delay(100L)
        emit(i * 2)
    }
}.filter { it > 10 }
 .map { "item: $it" }
 .onEach { println("collected: $it") }

This flow emits even numbers from 2 to 20 with a 100-millisecond delay between each emission. The filter operator removes values less than or equal to 10, so only "item: 12", "item: 14", "item: 16", "item: 18", and "item: 20" reach the onEach callback. Because flow {} is a building block, not a runner, nothing executes until a terminal operator like collect is called. This cold behavior matches RxJava's default for hot observables, but Flow makes it explicit and consistent.

Structured Concurrency

Structured concurrency means every spawned coroutine has a parent, and the parent waits for all children to finish before it completes. In RxJava, you might use CompositeDisposable to track multiple subscriptions, or Completable operators to wait for parallel work. With coroutines, supervisorScope or a CoroutineScope handles this natively. If one child fails, the others can keep running depending on whether you use SupervisorJob or a regular Job. This model prevents resource leaks and makes testing deterministic. Android's lifecycleScope and viewModelScope expose pre-built scopes tied to view and viewmodel lifecycles.

import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collectLatest

class ParallelFetch {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

    suspend fun fetchData() {
        val users = async(Dispatchers.IO) { fetchUsers() }
        val posts = async(Dispatchers.IO) { fetchPosts() }
        val comments = async(Dispatchers.IO) { fetchComments() }

        updateUi(users.await(), posts.await(), comments.await())
    }

    private suspend fun fetchUsers(): List<String> = listOf("Alice", "Bob")
    private suspend fun fetchPosts(): List<String> = listOf("Post A", "Post B")
    private suspend fun fetchComments(): List<String> = listOf("Comment 1")

    private fun updateUi(users: List<String>, posts: List<String>, comments: List<String>) {}
}

The async blocks launch three independent network calls on Dispatchers.IO and return Deferred objects. await() suspends the caller until each result is ready. Because the scope uses SupervisorJob, a failure in one fetch does not cancel the others. Once all three complete, updateUi receives the combined payload on the main thread. RxJava achieves the same pattern with zip or combineLatest, but the coroutine version avoids operator chain complexity.

Migrating from RxJava to Coroutines

Migration should be gradual. Start by replacing single-shot Single and Completable calls with suspend functions, then move Observable chains to Flow. Keep the RxJava dependency during the transition to allow coexistence. Use adapter libraries like kotlinx-coroutines-rx3 if you must bridge types temporarily, but treat them as short-term glue. Rewrite unit tests using runTest instead of TestScheduler, and update instrumentation tests to use coroutine-friendly test rules. This approach lets you verify each component before removing the old stack.

<!-- res/layout/activity_main.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ProgressBar
        android:id="@+id/progress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"/>

    <TextView
        android:id="@+id/result"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textSize="16sp"/>
</LinearLayout>

This layout provides a progress indicator and a result label for the UI examples used throughout the tutorial. When migrating screens, keep the XML intact and only replace the code-behind logic. Observables that populated these views can be swapped for flow collectors without touching the layout, which reduces migration risk and keeps design reviews focused on behavior rather than markup.

Common Pitfalls

One frequent mistake is forgetting that Flow is cold. Developers sometimes expect a flow to start emitting immediately after creation, but it only runs when collected. Another issue is mixing Dispatchers incorrectly — calling a UI-blocking operation on Dispatchers.IO and then trying to observe on Main will still freeze the interface. Use flowOn for upstream dispatching and let downstream collection choose its own context. With RxJava, the equivalent problem shows up as missing observeOn calls in long operator chains. Always prefer viewModelScope or lifecycleScope over global coroutines to tie work to component lifecycles. Finally, remember that catch in Flows recovers from errors silently, which can mask real bugs during development.

Conclusion

Coroutines and Flow offer a simpler, more Kotlin-native way to handle async work on Android. They replace much of RxJava's operator complexity with suspension points and structured concurrency, while still supporting reactive stream patterns. Migration is safest when done incrementally: suspend functions first, flows second, and lifecycle scoping throughout. RxJava remains a mature choice for teams with deep expertise or complex hot-stream requirements, but new Android projects should default to coroutines. Understanding both ecosystems lets you pick the right tool and migrate old code without rewriting everything at once.