RxJava in Android: The Complete Guide (From Basics to Production Patterns)

If you've ever written a callback inside a callback inside another callback just to do a network call, then show a result, then save to DB, you already understand why RxJava exists. It turns that messy, hard-to-read async code into clean, composable pipelines.
This guide covers everything: what RxJava is, how it works under the hood, all the operators you need to know, threading, error handling, memory management, and real-world patterns you'd actually use in production Android apps
What is RxJava? (And Why Should You Care?)
RxJava is a Reactive Extensions library for Java and Kotlin. It's built around one idea: treat data as a stream.
Whether it's a single API response, a list from a database, or a stream of button clicks, RxJava models all of it as an Observable stream that emits items over time. You attach an Observer to that stream, and it reacts to whatever gets emitted.
Think of it as a pipe-and-bucket model:
The pipe is your Observable (data flows through it)
The bucket is your Observer (it collects what comes out)
The valves and filters in between (are your operators)
The real power? You can transform, filter, combine, and control those streams with a rich set of operators and keep all your async code readable and maintainable.
The 5 Observable Types
Not every data source behaves the same way. RxJava gives you specialized types so you can be precise about what a stream will emit. This also helps the compiler and your teammates understand intent immediately.
1. Observable<T> — The General Purpose Stream
Emits 0 to many items, then completes or errors.
kotlin
Observable.fromArray("Delhi", "Mumbai", "Bangalore")
.subscribe { city -> println(city) }
Use when: UI events, real-time updates, lists of unknown size.
2. Single<T> — Exactly One Result
Emits one item or an error. No onComplete — emission is completion.
kotlin
Single.just(currentUser)
.subscribe(
{ user -> showProfile(user) },
{ error -> showError(error) }
)
Use when: API calls that return a single response (login, fetch user profile).
3. Maybe<T> — Zero or One Result
Emits 0 or 1 item, then completes (or errors). The "it might or might not exist" type.
kotlin
Maybe.fromCallable { cache.getUser(userId) } // returns null if not found
.subscribe(
{ user -> showFromCache(user) },
{ error -> handleError(error) },
{ loadFromNetwork() } // onComplete — nothing in cache
)
Use when: Cache lookups, optional config values, nullable DB queries.
4. Completable — Fire and Forget
Emits no items — only a completion signal or error. You only care whether it succeeded.
kotlin
Completable.fromAction { database.saveUser(user) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ showSuccessToast() },
{ error -> showError(error) }
)
Use when: Saving to DB, logging analytics events, sending a fire-and-forget request.
5. Flowable<T> — High-Volume Streams with Backpressure
Like Observable, but designed for when the producer is faster than the consumer. Has built-in backpressure strategies.
kotlin
Flowable.range(1, 1_000_000)
.onBackpressureBuffer()
.observeOn(Schedulers.computation())
.subscribe { process(it) }
Use when: Reading large files, sensor data, paginated DB queries, any stream that could overwhelm the subscriber.
Backpressure explained: Imagine a fire hose filling a small cup. The cup overflows. Backpressure is your strategy for that, buffer the excess, drop items, or only keep the latest. Observable has no such strategy and will crash with MissingBackpressureException. Flowable handles it gracefully.
Threading: subscribeOn vs observeOn
This is where most Android bugs come from. Get this wrong and you'll either crash with a CalledFromWrongThreadException or block the UI thread.
apiService.getUser(userId)
.subscribeOn(Schedulers.io()) // network call runs on IO thread
.observeOn(AndroidSchedulers.mainThread()) // result delivered on main thread
.subscribe { user -> updateUI(user) }
The golden rule:
subscribeOntells the SOURCE where to runIt works upstream, meaning it decides on which thread the Observable starts emitting. It doesn't matter where in the chain you put it, only the first one wins if you add multiple.
Observable.fromCallable { fetchFromDB() } // ← runs on IO, because of ↓ .subscribeOn(Schedulers.io()) // sets the SOURCE threadobserveOnswitches thread for everything BELOW itIt works downstream from the point you place it. Every operator and the final
subscribe {}after this call runs on the new thread..observeOn(AndroidSchedulers.mainThread()) // ← switch to UI thread .subscribe { textView.text = it } // ← now safe to touch UI
You can use observeOn multiple times in one chain to hop between threads:
kotlin
Observable.fromCallable { readFile() }
.subscribeOn(Schedulers.io()) // file read → IO thread
.map { parse(it) } // parsing → still IO
.observeOn(Schedulers.computation()) // switch → computation
.map { heavyProcessing(it) } // heavy work → computation
.observeOn(AndroidSchedulers.mainThread()) // switch → main
.subscribe { updateUI(it) } // UI update → main ✓
One-line memory trick:
subscribeOn= "where does the water come from?"observeOn= "where does the water go next?"
Scheduler Reference
| Scheduler | Use For |
|---|---|
Schedulers.io() |
Network calls, DB access, file I/O |
Schedulers.computation() |
CPU-bound work (parsing, encryption) |
Schedulers.single() |
Sequential tasks, one thread |
Schedulers.newThread() |
New thread per task (use sparingly, expensive) |
AndroidSchedulers.mainThread() |
All UI updates |
Operators: The Heart of RxJava
Operators sit between your Observable and Observer and transform the stream. This is where RxJava really shines.
Transformation Operators
map() Transform each item, one-to-one.
apiService.getUser()
.map { user -> user.displayName } // User → String
.subscribe { name -> showName(name) }
flatMap() Transform each item into a new Observable, then merge all results. Order not guaranteed.
// Login, then use token to fetch accounts
authService.login(email, password)
.flatMap { token -> accountService.getAccounts(token) }
.subscribe { accounts -> showAccounts(accounts) }
concatMap() Like flatMap, but sequential. Waits for each inner Observable to complete before starting the next. Order guaranteed.
// Submit logs one by one, in order
Observable.fromIterable(pendingLogs)
.concatMap { log -> apiService.submitLog(log) }
.subscribe { showSubmitSuccess() }
switchMap() Cancels the previous inner Observable when a new item arrives. Only the latest matters.
// Classic search: cancel old search when user types again
RxTextView.textChanges(searchEditText)
.debounce(300, TimeUnit.MILLISECONDS)
.switchMap { query -> apiService.search(query) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { results -> showResults(results) }
flatMapvsconcatMapvsswitchMapis one of the most common RxJava interview questions. Remember: flatMap = merge (unordered), concatMap = sequential, switchMap = cancel previous.
Filtering Operators
.filter { amount -> amount > 1000 } // only pass items that match
.take(5) // only first 5 items, then complete
.skip(2) // skip first 2 items
.distinct() // skip duplicates (all-time)
.distinctUntilChanged() // skip consecutive duplicates
.debounce(300, TimeUnit.MILLISECONDS) // wait for pause before emitting (search input)
.throttleFirst(1000, TimeUnit.MILLISECONDS) // emit first, ignore rest for 1s (button click)
Combining Operators
zip() — Combine two streams pairwise. Emits only when both have emitted.
// Two parallel API calls, combine results
Single.zip(
apiService.getUserProfile(),
apiService.getUserPosts()
) { profile, posts ->
DashboardData(profile, posts)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { dashboard -> renderDashboard(dashboard) }
merge() — Combine two streams, emit items as they arrive. No pairing.
Observable.merge(localDataStream, remoteDataStream)
.subscribe { data -> updateList(data) }
combineLatest() — Emit when either source emits, using the latest value from both.
// Form validation: re-validate whenever either field changes
Observable.combineLatest(
emailChanges,
passwordChanges
) { email, password ->
isValidEmail(email) && password.length >= 8
}
.subscribe { isValid -> submitButton.isEnabled = isValid }
Error Handling
RxJava errors are terminal once onError fires, the stream ends. Plan your error handling explicitly.
// Provide a fallback value on error
apiService.getUser()
.onErrorReturn { error -> guestUser }
// Switch to a fallback Observable on error
apiService.getUser()
.onErrorResumeNext { error -> cacheService.getUser() }
// Retry up to 3 times
apiService.getUser()
.retry(3)
// Retry with exponential backoff
apiService.getUser()
.retryWhen { errors ->
errors.zipWith(Observable.range(1, 3)) { _, attempt -> attempt }
.flatMap { attempt ->
Observable.timer(attempt * 2L, TimeUnit.SECONDS)
}
}
Subjects: Observable and Observer in One
A Subject is both an Observable (you can subscribe to it) and an Observer (you can push values into it). This makes it useful for bridging non-reactive code with reactive streams.
val subject = PublishSubject.create<String>()
subject.subscribe { event -> println(event) }
subject.onNext("user_clicked_buy") // push from anywhere subject.onNext("user_navigated_back")
| Subject | Behavior |
|---|---|
PublishSubject |
Only emits to current subscribers. Past items lost. |
BehaviorSubject |
Replays the last item to new subscribers. |
ReplaySubject |
Replays all past items to any new subscriber. |
AsyncSubject |
Only emits the last item, and only on onComplete. |
Android use cases:
PublishSubject→ event bus (button clicks, navigation events)BehaviorSubject→ state holder (similar toStateFlow)
Memory Leak Prevention with CompositeDisposable
Every subscribe() call returns a Disposable. If you forget to dispose it when the screen is destroyed, RxJava holds a reference to your Activity/Fragment — memory leak.
The idiomatic fix is CompositeDisposable in your ViewModel:
class UserViewModel : ViewModel() {
private val disposables = CompositeDisposable()
fun loadUser(userId: String) {
val d = userRepository.getUser(userId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ user -> _userState.value = user },
{ error -> _errorState.value = error.message }
)
disposables.add(d)
}
override fun onCleared() {
disposables.clear() // cancels all subscriptions
super.onCleared()
}
}
Real-World Production Patterns
Pattern 1: Search with Debounce
RxTextView.textChanges(searchBox)
.skipInitialValue()
.debounce(300, TimeUnit.MILLISECONDS)
.distinctUntilChanged()
.filter { query -> query.length >= 2 }
.switchMap { query ->
apiService.search(query)
.onErrorReturnItem(emptyList())
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { results -> adapter.submitList(results) }
Pattern 2: Parallel API Calls
Single.zip(
apiService.getUser().subscribeOn(Schedulers.io()),
apiService.getNotifications().subscribeOn(Schedulers.io()),
apiService.getRecentOrders().subscribeOn(Schedulers.io())
) { user, notifications, orders ->
HomeScreenData(user, notifications, orders)
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { data -> renderHomeScreen(data) }
Pattern 3: Sequential Dependent Calls
// Login → get token → fetch profile with token
authService.login(credentials)
.flatMap { token -> profileService.getProfile(token) }
.flatMap { profile -> settingsService.getSettings(profile.id) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ settings -> navigateToDashboard(settings) },
{ error -> showLoginError(error) }
)
RxJava vs Kotlin Coroutines + Flow
Here's the honest answer:
| RxJava | Coroutines + Flow | |
|---|---|---|
| Language | Java + Kotlin | Kotlin only |
| Operator richness | Extensive (100+) | Growing |
| Backpressure | Flowable |
Flow (built-in) |
| Learning curve | Steep | Moderate |
| Jetpack Compose | Limited | Native |
| Industry direction | Legacy codebases | Greenfield standard |
The honest take: For new Android projects today, Kotlin Coroutines + Flow is the preferred choice, it's more idiomatic Kotlin, has better tooling, and integrates seamlessly with Jetpack. But RxJava still powers a massive amount of production code. If you're joining a company with an existing codebase (especially fintech, e-commerce, or older product companies), there's a solid chance RxJava is running their core flows. Understanding it deeply makes you valuable in both worlds.
Summary: When to Use What
| Scenario | Use |
|---|---|
| Single API call | Single |
| Optional/nullable result | Maybe |
| Write operation, no result | Completable |
| List / stream of items | Observable |
| High-volume / fast stream | Flowable |
| Search box | debounce + switchMap |
| Parallel calls | zip |
| Sequential dependent calls | flatMap / concatMap |
| Form validation | combineLatest |
| Event bus | PublishSubject |
| State holder | BehaviorSubject |
RxJava has a steep learning curve, but once it clicks, you start seeing your entire codebase differently as data flowing through transformable pipelines. That mental model alone makes you a better developer, even if you switch to Coroutines on your next project.
Thanks for reading.



