Why Migrate?
RxJava served the Android community well for years. But with Kotlin becoming the default language for Android, coroutines offer a more idiomatic approach to asynchronous programming.
At Glance, our SDK had 40+ RxJava streams handling everything from content fetching to analytics batching. Here's why we decided to migrate:
- Learning curve: New Kotlin developers struggled with RxJava's operator zoo (flatMap vs switchMap vs concatMap, anyone?)
- Stack traces: RxJava stack traces are notoriously unhelpful. Coroutines give you readable stack traces.
- Library size: RxJava + RxAndroid + RxKotlin added ~2.5MB to our SDK. Coroutines are built into the Kotlin stdlib.
- First-party support: Google's Jetpack libraries are coroutines-first. Fighting against the ecosystem is a losing battle.
The Migration Patterns
Observable → Flow
// Before (RxJava)fun getUpdates(): Observable<Update> =
Observable.interval(5, TimeUnit.SECONDS)
.flatMap { api.fetchUpdates().toObservable() }
// After (Coroutines)
fun getUpdates(): Flow<Update> = flow {
while (true) {
emit(api.fetchUpdates())
delay(5.seconds)
}
}
Single → suspend function
// Beforefun getUser(id: String): Single<User> =
api.getUser(id).subscribeOn(Schedulers.io())
// After
suspend fun getUser(id: String): User =
withContext(Dispatchers.IO) { api.getUser(id) }
CompositeDisposable → CoroutineScope
// Beforeclass MyViewModel : ViewModel() {
private val disposables = CompositeDisposable()
fun load() {
disposables.add(repo.getData().subscribe { / ... / })
}
override fun onCleared() = disposables.clear()
}
// After
class MyViewModel : ViewModel() {
fun load() {
viewModelScope.launch {
val data = repo.getData()
// ...
}
}
// Automatic cancellation!
}
The Gotchas
1. Cold vs Hot Streams
RxJava's Subject → Coroutines' SharedFlow or StateFlow. But the semantics are different:
BehaviorSubject≈MutableStateFlow(but StateFlow requires an initial value)PublishSubject≈MutableSharedFlow(replay = 0)ReplaySubject≈MutableSharedFlow(replay = n)
2. Error Handling
RxJava has onError handlers on every stream. Coroutines use structured concurrency — an unhandled exception cancels the parent scope. We had to add CoroutineExceptionHandler to critical scopes.
3. Backpressure
RxJava has explicit backpressure strategies (Flowable). Flow doesn't — it's inherently sequential. For our high-throughput analytics pipeline, we used buffer() and conflate() operators.
Testing
Coroutines testing is actually simpler:
@Testfun
test data loading() = runTest {val viewModel = MyViewModel(fakeRepo)
viewModel.load()
advanceUntilIdle()
assertEquals(expectedData, viewModel.state.value)
}
Compare that to the RxJava testing boilerplate with TestObserver, TestScheduler, and RxJavaPlugins.setIoSchedulerHandler.
Performance Benchmarks
We benchmarked equivalent operations:
| Operation | RxJava | Coroutines | Winner |
| Simple async call | 2.1ms | 1.4ms | Coroutines |
| Stream of 1000 items | 8.3ms | 5.7ms | Coroutines |
| Combining 5 streams | 4.2ms | 3.1ms | Coroutines |
| Memory per stream | ~2KB | ~0.5KB | Coroutines |
The Result
After 2 months of migration:
- SDK size reduced by 1.8MB
- Stack trace readability improved dramatically — debugging went from hours to minutes
- New developer onboarding on async code dropped from 2 weeks to 3 days
- Test code reduced by ~45%
Should You Migrate?
If you're starting a new project: use coroutines. No question.
If you have an existing RxJava codebase: migrate incrementally. There's no need to do it all at once. Both can coexist using kotlinx-coroutines-rx3 bridge library.