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

  1. Add .observeOn(AndroidSchedulers.mainThread()) as the last step before subscribing a UI-bound observer.
  2. Move the subscribe() or dispose() call onto the main thread, e.g. new Handler(Looper.getMainLooper()).post(...) or Activity#runOnUiThread(...).
  3. 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.
  4. 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.
  5. 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

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.