
Reactive Programming Crashes: RxSwift, RxJava & Combine
Reactive programming has quietly become the default way modern mobile apps model asynchronous events, but it also introduces a distinct class of crashes that traditional debugging misses. When an RxSwift Observable, an RxJava Flowable, or a Combine Publisher misbehaves, the failure almost never surfaces at the line that caused it. An RxSwift crash usually appears as a subscription killed early, a retain cycle, or an onError delivered after dispose; an RxJava crash surfaces as an UndeliverableException; a Combine crash hides inside a forgotten AnyCancellable. Because reactive frameworks defer and re-route errors through operator chains and schedulers, the stack trace you see is rarely the stack trace that matters.
This guide walks through the most common reactive-programming crash patterns across all three frameworks — RxSwift, RxJava, and Combine — and shows how to trace each one back to its real source.
Why reactive streams crash differently
In imperative code, an error happens at a single point in a single thread, and the stack trace points straight at it. Reactive streams break both of those assumptions. An operator chain can re-route an onError across schedulers, buffer it behind backpressure, or deliver it to a subscriber that has already been disposed. The ReactiveX Observable contract formalizes the rules that govern this — most importantly, that a stream must not emit onNext after onError or onComplete, and that onError/onComplete must be terminal. Almost every hard-to-debug reactive crash is a violation of one of those two rules, or a memory-management mistake made possible by the asynchronous nature of the stream.
That contract also explains why reactive bugs often look like memory leaks, silent failures, or race conditions rather than clean exceptions. A subscription that is disposed a moment too early fails silently; a subscription that is never disposed leaks and eventually triggers a crash somewhere unrelated. Recognizing the failure mode is the first step to fixing it.
RxSwift: the silent killers
RxSwift is Swift's ReactiveX implementation, and its most dangerous crash patterns are the ones that do not crash immediately. They fail slowly, in production, under load. The RxSwift repository documents the core abstractions, but the failure modes live in how developers wire them up.
DisposeBag lifetime bugs
Every RxSwift subscription returns a Disposable, and the convention is to add it to a DisposeBag that is released when the owning object deallocates. The classic bug is disposing the bag too early: a DisposeBag declared as a local variable inside a function is deallocated the moment the function returns, which cancels every subscription you just created. The result is not a crash but a silent failure — your network call or observation simply never fires its side effects, and you only notice when a screen renders empty.
func loadProfile() {
let bag = DisposeBag() // released when the function returns
api.fetchProfile()
.subscribe(onNext: { profile in
self.render(profile) // never called
})
.disposed(by: bag)
}The fix is to hold the DisposeBag on the object that should own the subscription's lifetime, not on the call stack. The mirror-image bug is never disposing at all, which retains the subscription and its captured values long after the screen is gone — a slow leak that eventually surfaces as an out-of-memory termination.
Retain cycles in subscribe closures
Because subscribe(onNext:) closures are retained for the lifetime of the subscription, capturing self strongly inside them creates a retain cycle: the subscription holds the closure, the closure holds self, and self holds the DisposeBag that holds the subscription. Nothing can ever deallocate. Use [weak self] in every subscription closure, and consider the take(until:) or .takeUntil patterns to scope a stream to a view controller's lifecycle.
Thread safety and subject reentrancy
BehaviorSubject, PublishSubject, and Variable are not thread-safe, and neither is mutating shared state from an onNext handler that may fire on a background scheduler. Emitting into a subject from multiple threads can corrupt its internal state or produce events in an order that violates the contract. The more subtle failure is reentrancy: emitting an event into a subject from inside its own subscriber callback while the subject is still delivering a previous event, which RxSwift handles with a recursion warning that can escalate into undefined behavior. Route mutations through a serial scheduler or a dedicated dispatch queue.
The related main-thread failure is observeOn(MainScheduler.instance): when an event emits off the main thread and your onNext closure touches UIKit, you get an immediate crash. Always observeOn the main scheduler before mutating UI, and audit every chain that crosses threads.
RxJava: UndeliverableException and backpressure
RxJava's failure modes are more explicit than RxSwift's, because the RxJava library surfaces them as typed exceptions. The challenge is that they surface on threads you were not looking at.
UndeliverableException
The single most confusing RxJava crash in production is io.reactivex.exceptions.UndeliverableException, which wraps an onError that arrived after a subscriber was disposed. When you dispose a subscription, RxJava's operators (like debounce, delay, or a network Single) may still be mid-flight; if they subsequently emit an error, there is no subscriber to deliver it to, so RxJava wraps it and hands it to the global RxJavaPlugins.setErrorHandler. If you have not installed a handler, that wrapped exception crashes the app on a background thread with a stack trace that points nowhere near your code.
RxJavaPlugins.setErrorHandler(throwable -> {
if (throwable instanceof UndeliverableException) {
// Log and swallow — the subscriber is already gone
} else {
Thread.currentThread().getUncaughtExceptionHandler()
.uncaughtException(Thread.currentThread(), throwable);
}
});The real fix is upstream: cancel or clean up in-flight work on dispose, or use operators that tolerate disposal without emitting afterward.
MissingBackpressureException
MissingBackpressureException happens when a hot producer emits faster than a subscriber can consume and the chain has no backpressure strategy. In RxJava 2 and 3, Flowable is the backpressure-aware type; using Observable for high-volume sources (sensor streams, location updates, socket reads) removes backpressure entirely and eventually throws. Switch the source to Flowable and apply an explicit strategy — onBackpressureBuffer, onBackpressureDrop, or onBackpressureLatest — as described in the RxJava wiki on backpressure.
CompositeDisposable leaks and protocol violations
CompositeDisposable is RxJava's equivalent of the DisposeBag, and it leaks the same way: created locally and forgotten, or never disposed on teardown. The protocol violation is subtler. Emitting onNext after onError or onComplete — for example, from a custom Subject or a misbehaving operator — violates the ReactiveX contract and is undefined behavior; depending on the version, it throws, is silently dropped, or corrupts downstream state. Guard every custom emitter with a "terminated" flag.
final class SafeEmitter<T> {
private volatile boolean terminated;
void emit(Emitter<T> e, T value) {
if (!terminated) e.onNext(value);
}
void complete(Emitter<T> e) {
if (!terminated) { terminated = true; e.onComplete(); }
}
}Combine: cancellables and retain cycles
Combine is Apple's native reactive framework, and its crash patterns are the most memory-centric of the three. The Combine framework documentation makes one point relentlessly: a subscription is only alive as long as its AnyCancellable is retained.
Forgetting to store AnyCancellable
When you call .sink or .assign(to:on:) on a Publisher, it returns an AnyCancellable. If you do not store that cancellable in a property or a Set<AnyCancellable>, it is deallocated immediately and the subscription cancels before a single value is delivered. The symptom is identical to the RxSwift DisposeBag bug: a screen that silently never updates.
private var cancellables = Set<AnyCancellable>()
func observe() {
model.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
self?.render(state)
}
.store(in: &cancellables) // keeps the subscription alive
}The AnyCancellable documentation is explicit: it calls cancel() on deinit, which is exactly the behavior that produces the silent-failure symptom when you forget to store it.
.sink and .assign retain cycles
The .sink and .assign(to:on:) operators retain their closures, so capturing self strongly inside them creates the same retain cycle as an RxSwift subscription. With .assign(to:on:), passing self as the on: object for a property that lives on self is a textbook cycle. Always capture self weakly inside .sink closures and prefer assign(to:on:) only when the target does not create a cycle back to the subscriber.
Subject thread-safety and reentrancy
PassthroughSubject and CurrentValueSubject are not thread-safe. Sending values from multiple threads, or mutating state inside a .sink that is re-entered by the subject, can produce torn state or recursive delivery that stalls the run loop. The CurrentValueSubject documentation notes that it stores a current value, which makes the reentrancy hazard worse: a value sent during delivery is immediately observable to the next subscriber, mid-emission. Serialize sends onto a single queue and avoid re-emitting from inside a subscriber. And mirror the RxSwift main-thread rule: receive(on: DispatchQueue.main) before any UI mutation, because a publisher delivering on a background queue will crash UIKit just as fast as any RxSwift chain.
A unified debugging workflow
Reactive crashes share a debugging shape regardless of framework. First, confirm the failure mode: is it a silent failure (disposed too early), a leak (never disposed), a thread violation (UI mutated off-main), or a protocol violation (onNext after terminal, or undeliverable error)? Second, trace the stream's lifecycle, not the crash site — the bug is usually where the subscription is created or disposed, not where the exception is logged. Third, audit every closure for self captures, because retain cycles are the single most common root cause across all three frameworks, and they are closely related to the leaks and OOM crashes covered in our memory leak debugging guide.
Thread violations are their own category: a reactive stream that hops schedulers is a race condition in disguise, and the resulting crash is indistinguishable from the ones we catalog in the thread-safety and race-condition guide. And the silent failures — subscriptions that dispose too early or errors that vanish into an UndeliverableException — are the exact class of bug we break down in our silent failure debugging guide.
Reactive programming rewards a monitoring-first mindset. Because so many of these failures are silent or off-thread, you need crash reporting that captures the full stream context — the subscription lifecycle, the scheduler, and the operator chain — not just the final stack frame. That is the kind of visibility Bugspulse is built to give mobile teams, so a stray UndeliverableException or a forgotten AnyCancellable never reaches your users unobserved.
Fix reactive crashes before your users do
Whether you are hunting an RxSwift retain cycle, an RxJava UndeliverableException, or a Combine AnyCancellable that was never stored, the pattern is the same: find the subscription, audit the closure, and verify the thread. Instrument your streams, log dispose events, and catch undeliverable errors at the framework boundary — then back it all with crash reporting that understands reactive lifecycles.
Ready to see every reactive crash in your app — including the silent ones — before your users do? Sign up for Bugspulse and start tracking RxSwift, RxJava, and Combine failures in minutes.