ReactiveX/RxAndroid · error · IllegalStateException
Expected to be called on the main thread but was {threadName
Error message
Expected to be called on the main thread but was {threadName} What it means
RxAndroid throws this IllegalStateException from MainThreadDisposable.verifyMainThread() when an observer or disposable that requires the Android main thread (e.g. RxBinding view observers, MainThreadDisposable subclasses) is subscribed, disposed, or otherwise used from a non-main thread. The check compares Looper.myLooper() with Looper.getMainLooper() and includes the offending thread's name in the message. It exists because view/UI operations and listener registration/removal are only safe on the main thread, and the library enforces that as a precondition rather than risking undefined UI behavior.
Source
Thrown at rxandroid/src/main/java/io/reactivex/rxjava3/android/MainThreadDisposable.java:57
* @Override protected void onDispose() {
* // TODO undo behavior
* }
* });
* }
* </code></pre>
*/
public abstract class MainThreadDisposable implements Disposable {
/**
* Verify that the calling thread is the Android main thread.
* <p>
* Calls to this method are usually preconditions for subscription behavior which instances of
* this class later undo. See the class documentation for an example.
*
* @throws IllegalStateException when called from any other thread.
*/
public static void verifyMainThread() {
if (Looper.myLooper() != Looper.getMainLooper()) {
throw new IllegalStateException(
"Expected to be called on the main thread but was " + Thread.currentThread().getName());
}
}
private final AtomicBoolean unsubscribed = new AtomicBoolean();
@Override
public final boolean isDisposed() {
return unsubscribed.get();
}
@Override
public final void dispose() {
if (unsubscribed.compareAndSet(false, true)) {
if (Looper.myLooper() == Looper.getMainLooper()) {
onDispose();
} else {
AndroidSchedulers.mainThread().scheduleDirect(this::onDispose);View on GitHub (pinned to afaea28046)
Solutions
- Add .observeOn(AndroidSchedulers.mainThread()) as the last step before subscribing a UI-bound observer.
- Move the subscribe() or dispose() call onto the main thread, e.g. new Handler(Looper.getMainLooper()).post(...) or Activity#runOnUiThread(...).
- If disposing from a background thread, dispose the upstream Disposable directly (the one returned by subscribe()) instead of the MainThreadDisposable instance, or post the dispose to the main thread.
- In unit tests, run with Robolectric (@RunWith(RobolectricTestRunner.class)) so a real main looper exists, or use RxAndroidPlugins.setMainThreadSchedulerHandler to swap in Schedulers.trampoline() and avoid looper-dependent observers.
- If you implemented MainThreadDisposable yourself, re-read its class docs: verifyMainThread() is meant to be called in onSubscribe and undone in onDispose, both of which must then happen on the main thread.
Example fix
// before
apiService.loadUser()
.subscribeOn(Schedulers.io())
.subscribe(userViewObserver); // MainThreadDisposable-based observer -> ISE
// after
apiService.loadUser()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(userViewObserver); Defensive patterns
Strategy: validation
Validate before calling
boolean isOnMainThread = Looper.myLooper() == Looper.getMainLooper();
if (!isOnMainThread) {
// route the work to the main thread instead of subscribing/disposing here
new Handler(Looper.getMainLooper()).post(() -> doSubscribeOrDispose());
} Try / catch
try {
MainThreadDisposable.verifyMainThread(); // or the risky subscribe/dispose
} catch (IllegalStateException e) {
if (e.getMessage() != null && e.getMessage().contains("main thread")) {
new Handler(Looper.getMainLooper()).post(() -> doSubscribeOrDispose());
} else {
throw e;
}
} Prevention
- End every UI-bound chain with .observeOn(AndroidSchedulers.mainThread()) before subscribe().
- Only call dispose() on the Disposable returned by subscribe(), or post disposal to the main thread.
- Run Android unit tests that touch UI observers with Robolectric, and swap in Schedulers.trampoline() via RxAndroidPlugins for pure logic tests.
- Treat this ISE as a design signal: do not touch view observers from IO/computation callbacks.
When it happens
Trigger: Subscribing an observer that extends MainThreadDisposable (such as RxBinding's view observers) from a background thread; calling dispose() on such an observer off the main thread; emitting into such an observer from an IO/computation scheduler without observeOn(AndroidSchedulers.mainThread()); unit tests that subscribe on a plain JUnit thread with no Android looper at all.
Common situations: Piping a network or database stream straight into a view observer without observeOn(mainThread()); calling dispose() inside a Schedulers.io() lambda or a Retrofit callback thread; refactoring a subscribe call out of onCreate into a background worker; running Android unit tests (non-Robolectric) where Looper.getMainLooper() is null or different, causing confusing failures; mixing Kotlin coroutines that resume on Dispatchers.IO and then touching RxBinding subscriptions.
Related errors
AI-assisted analysis of ReactiveX/RxAndroid@afaea28046 (2026-08-14).
Data as JSON: /api/errors/4660e8e05e779660.
Report an issue: GitHub.