cswork / libraryOpen practice workspace

Articles

RxSwift Interview Questions: Disposal, Schedulers, and Shared Subscriptions

Practice RxSwift interviews through a search-screen case: DisposeBag ownership, scheduler placement, shared subscriptions, replay scope, and leak tests.

An RxSwift search screen disappears, but its request logging continues. The screen also makes two identical API requests for one query because a results table and a count label each subscribe to the same cold pipeline. Adding [weak self] to a closure might help one ownership cycle, but it will not automatically decide when the subscription ends or whether the network producer is shared. The official RxSwift getting-started guide treats disposal, scheduling, and sharing as distinct concerns.

This guide works through one original iOS search fixture. It is an interview practice exercise, not a candidate report that a specific company asks RxSwift. Official RxSwift docs support library behavior; screen lifecycle choices, fake network behavior, and test expectations are explicitly scenario assumptions. For a related ownership prompt, Explain iOS ARC and avoid retain cycles helps separate object lifetime from subscription lifetime.

The interview fixture: search, count, and navigation

An iOS product-search screen observes queryText, requests product results, binds the array to a table, and binds results.count to a label. Typing a new query should make the old result irrelevant. The screen can be pushed, popped, and pushed again. A SearchViewModel owns screen-specific subscriptions; a SearchAPI produces a cold Observable for each query. Assume a second screen must not inherit this screen's replayed result. A query failure should show an error state while later queries remain usable.

The interviewer should clarify whether the view model lives only for the screen or in a coordinator, whether the API's Disposable actually cancels its URLSessionTask, and whether both UI consumers subscribe at the same time. Those facts change the correct lifetime and sharing policy. For the worked exercise, the view model is intended to die when the screen is popped, the fake API records its subscribe and dispose events, and both UI bindings coexist while the screen is visible.

Five urgent questions follow. Who owns each Disposable? Does DisposeBag release a retained view model if the subscription itself keeps it alive? Which operator moves subscription work, and which delivers callbacks on the main scheduler? Why do two subscribers cause two cold side effects? What exactly happens when the last subscriber leaves a shared stream? A good answer draws an ownership graph and a subscription timeline before editing Swift code.

Question 1: What does dispose actually mean?

The RxSwift guide says a subscription returns a Disposable. Calling dispose() cancels production and releases resources associated with upcoming elements. A sequence that completes or errors releases its internal resources too. A never-ending source such as button taps needs an explicit lifetime boundary. This is why “it eventually completes” is not an acceptable explanation for an infinite UI event stream.

Disposal is not a .completed event delivered to the observer. If a view disappears and the subscription is disposed, the UI should stop receiving future values, but a subscribe(onCompleted:) callback is not the cleanup signal for that action. Use do(onDispose:) or a fake source's returned Disposable to observe teardown. The API adapter must connect that teardown to its own operation if actual network cancellation is required.

The official guide also gives an important concurrency qualification: if production and disposal happen on different schedulers, you cannot make a blanket guarantee that no callback races with a concurrent dispose() call. When observer delivery and disposal occur on the same serial scheduler, the ordering is well defined. In a UI answer, say which scheduler serializes delivery and teardown instead of promising magical instantaneous cancellation across threads.

For this fixture, distinguish three times: the user pops the screen, the owner releases or replaces its bag, and the network adapter observes disposal. A test should record all three. If the fake source logs disposed but the real URLSessionTask continues, the Rx chain behaved as designed while the adapter's cancellation contract was incomplete.

Question 2: Does DisposeBag solve the leaked view model?

A DisposeBag disposes its stored Disposables when the bag deinitializes. Assigning a new bag replaces the old one and disposes old subscriptions once the old bag is released. The bag has no dispose() method; use a CompositeDisposable when explicit immediate disposal is the design. Put a subscription in the owner whose lifetime matches the work. A controller-owned bag may be too short if the view model intentionally survives the screen; a long-lived coordinator bag may be too long for screen-only search requests.

But a bag is not a cycle breaker by itself. Draw view model → bag → subscription → closure → view model. If the closure strongly captures the model, ARC can keep the model and bag alive after the navigation owner releases them. Then the bag's deinit never runs, so relying on its eventual disposal becomes circular reasoning. A [weak self] capture can break that particular strong edge, but only if the closure does not reconstruct another strong path through an object it retains. The RxSwift guide recommends making the lifetime explicit; ordinary Swift ARC rules determine the retain graph.

One sketch is:

final class SearchViewModel {
    private var screenBag = DisposeBag()

    func bind(results: Observable<[Product]>) {
        results
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] products in
                self?.visibleProducts = products
            })
            .disposed(by: screenBag)
    }

    func stopScreenWork() {
        screenBag = DisposeBag()
    }
}

This is a lifetime sketch, not a paste-ready class: Product and visibleProducts belong to the app. stopScreenWork() is appropriate only if the view model can remain alive after the screen stops needing its bindings. If navigation deallocates the entire view model reliably, the bag's deinit may already provide the boundary. Test deallocation and onDispose under the actual owner graph rather than adding a reset method reflexively.

Question 3: Which work does each scheduler operator move?

RxSwift's operator API distinguishes subscribe(on:), which schedules subscription and unsubscription logic, from observe(on:), which schedules downstream observer callbacks. For a UI binding, observe(on: MainScheduler.instance) makes the downstream UI update happen on the main scheduler. It does not claim that all upstream decoding, transport, or subscription side effects were moved to the main scheduler.

Conversely, placing subscribe(on: backgroundScheduler) can move source subscription side effects away from the caller, but it is not a replacement for arranging UI delivery on the main scheduler. Trace the order of operators. If an expensive synchronous map runs before observe(on: MainScheduler.instance), it executes on whichever upstream scheduler produces that value; if it runs after, it can burden the main thread. A network API may already call back on a queue of its own, so measure actual thread behavior instead of assuming a scheduler name explains the whole pipeline.

The scheduler reference notes that MainScheduler is suitable for observation on the main thread. ConcurrentMainScheduler has a different optimization for subscription work. In the fixture, the table and count bindings must update on main. The fake API should log the thread at subscription, result emission, and UI observer callback. That three-point trace reveals whether the intended boundary was placed before or after CPU-heavy transformation.

Do not equate RxSwift schedulers with Swift Concurrency actors. If the application also uses @MainActor state or async tasks, say where the bridge occurs and test its isolation contract. The Rx scheduler declaration alone does not prove an actor-isolated property may be mutated safely under every compiler setting.

Question 4: Why do two subscribers trigger two requests?

In this fixture, SearchAPI.search(query) returns a cold Observable whose side effect starts on subscription. If table and count label each independently subscribe to an unshared result chain, each can trigger a separate API call for the same query. The RxSwift getting-started guide shows exactly this motivation for sharing a search result stream before binding it to multiple UI elements.

Construct the shared stream once and give both consumers the same instance:

let sharedResults = queryText
    .distinctUntilChanged()
    .flatMapLatest { query in api.search(query) }
    .observe(on: MainScheduler.instance)
    .share(replay: 1, scope: .whileConnected)

sharedResults.bind(to: tableResults).disposed(by: screenBag)
sharedResults.map(\.count).bind(to: countLabel).disposed(by: screenBag)

The bindings are illustrative; actual RxCocoa binder types and queryText optionality must match the app. flatMapLatest implements this fixture's latest-query policy, while share(replay: 1, scope: .whileConnected) shares one upstream subscription among overlapping downstream consumers and replays one recent value to a late consumer in that connection. Creating the entire chain twice in two computed properties would still create two separate shared instances and two requests. The location and identity of the shared variable matter.

share is not a global network cache. The SubjectLifetimeScope API says .whileConnected uses a subject per connection and clears it after the subscriber count falls to zero. A later screen connection can resubscribe and recompute. .forever preserves one subject across connections, which may retain replayed data beyond the screen's intended lifetime and affect retry behavior. Choose the scope based on who is allowed to see a past value and for how long, not because “replay 1” sounds efficient.

Question 5: What happens when a query fails?

An Observable's grammar is zero or more next events followed by either error or completion, according to the ObservableType reference. After an unhandled error, that subscription cannot accept the next query. In the search fixture, catch errors inside the flatMapLatest request so one failed search becomes an error state while the outer text stream stays subscribed. That is a policy choice: a fatal authentication error might instead end the feature and require reauthentication.

If the app maps every error to [], the table stays safe but users cannot tell an empty result from a failed request. A richer state enum such as .loading(query), .results(query, products), and .failed(query, errorKind) makes the UI and tests more honest. Keep sensitive server error detail out of user-facing copy. Place share after the policy transformation if both table and status label need the same result/error state; otherwise each observer may repeat recovery side effects.

Changing queries while a request is in flight should end the old inner subscription through flatMapLatest. But whether the underlying network task stops depends on the SearchAPI adapter's returned Disposable. In a fake, make the old request attempt a late emission and assert it does not update visible state; separately assert the adapter receives disposal. This separates stale UI prevention from transport resource savings.

An ownership and subscription test matrix

These expected outcomes are original practice for the stated fixture, not claims about a company's interview.

ActionObservable evidenceOwnership or side-effect evidence
Open search screen onceTable and count show the same resultOne API subscription for one query
Attach count label slightly laterIt receives current shared result if still connectedNo second API subscription
Type a newer queryOld result cannot replace current UI stateOld inner subscription receives dispose
Fail one query, then type againError state, then later successOuter text subscription remains alive
Pop the screenNo later UI updateScreen-owned subscriptions dispose; view model deallocates if intended
Push a new screenFresh intended state, not old user's replayNew .whileConnected connection may create new API subscription
Reopen repeatedlyNo accumulating callbacksStable owner count and appropriate resource trend

Test with controlled Observables, not wall-clock sleeps. Log subscribed, disposed, and emitted query IDs at the fake API boundary; use a weak reference to assert that the view model deallocates after navigation if that is the contract. The RxSwift guide's debugging section describes debug() and Resources.total for development diagnostics, but a resource count is a clue, not proof of which object is retained. Inspect Xcode's memory graph for the exact strong path when the weak deallocation assertion fails.

A concise interview answer

Say: “I will draw the owner graph and the query timeline separately. The screen's bag owns its subscriptions, but I will verify that no closure keeps the owner alive and prevents bag deinit. subscribe(on:) affects subscription work; observe(on:) places downstream UI delivery on main. Since table and count need the same cold results, I construct one shared stream, choose .whileConnected because replay must not outlive this screen connection, and bind both to it. flatMapLatest releases the old inner subscription; the API adapter decides whether that disposal aborts transport. I test one API subscription, old-query disposal, error recovery, and owner deallocation.”

That answer names the important limits. It does not claim that [weak self] disposes work, that a DisposeBag necessarily deinitializes, or that share caches results forever. A test that checks the visible result without counting subscriptions could miss duplicate network work; a count-only test could miss a retained screen. Both are necessary here.

Sources and further reading