Back to all posts

Kotlin Coroutines vs RxJava — A Practical Migration Guide

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

// Before

fun 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

// Before

class 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:

  • BehaviorSubjectMutableStateFlow (but StateFlow requires an initial value)
  • PublishSubjectMutableSharedFlow(replay = 0)
  • ReplaySubjectMutableSharedFlow(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:

@Test

fun 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:

OperationRxJavaCoroutinesWinner
Simple async call2.1ms1.4msCoroutines
Stream of 1000 items8.3ms5.7msCoroutines
Combining 5 streams4.2ms3.1msCoroutines
Memory per stream~2KB~0.5KBCoroutines

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.