Articles
RxJava Interview Questions: Backpressure, Scheduler Boundaries, and Disposal
Trace RxJava 3 backpressure, scheduler queues, and disposal with demand ledgers, cancellation races, and five related coding interview exercises.
A producer emits 10,000 updates each second, but your consumer handles only 100. Adding observeOn makes the caller responsive, yet memory keeps growing. Then the screen closes, you dispose the subscription, and an already-started database write still finishes. These are three boundaries: demand, queued work, and cancellation. A useful RxJava interview answer traces each separately.
This guide uses RxJava 3, with the 3.1.12 API as its reference. Official behavior is linked where it first appears. The examples and preparation recommendations are original practice, not reports of a particular employer's questions.
Which RxJava type belongs at the boundary?
Official fact: Flowable implements Reactive Streams and supports requested demand. RxJava 3 Observable uses a different protocol without backpressure infrastructure. Their names do not tell you whether a source is hot, cold, finite, or expensive. Flowable API, Observable API.
For an original type-selection exercise, classify a search result, an optional cache lookup, a completed save, and a stream of sensor readings. A single result suggests Single; an optional result suggests Maybe; completion without a value suggests Completable. These cardinalities are defined in the official Single, Maybe, and Completable source documentation.
For the readings, ask whether the producer can actually pause. A finite collection can wait for requests. A callback from an external device may keep arriving regardless. Choosing Flowable gives you an interface that observes requested demand; it does not physically slow the device. You still need a policy at the callback bridge.
A strong answer states both the value contract and the resource contract: “Every payment event must be retained durably, but an intermediate temperature display may retain only the newest value.” Those two streams should not share an overflow policy merely because both contain many values.
How do you trace backpressure without guessing?
Official fact: Reactive Streams bounds delivered onNext signals by requested demand, while cancellation can take time to propagate. Demand is permission to deliver, not a promise that the consumer has finished its business operation. Reactive Streams JVM specification.
Use this original ledger for a direct, demand-aware source with no intervening prefetch operator. It deliberately separates deliveries from completed work.
| Step | Additional request | Cumulative delivered | Outstanding delivery allowance | Application state |
|---|---|---|---|---|
| Subscribe | 0 | 0 | 0 | Nothing started |
| Request two | 2 | 0 | 2 | Source may deliver two |
| Receive A | 0 | 1 | 1 | A processing |
| Receive B | 0 | 2 | 0 | B processing or waiting |
| Finish A | 0 | 2 | 0 | Completion alone creates no new demand |
| Request one | 1 | 2 | 1 | One more delivery permitted |
Now add a failure: processing A starts a network request and returns immediately. If onNext requests another item at that point, your ledger tracks callback returns, not completed requests. The application can create thousands of concurrent operations despite apparently requesting one at a time.
The preparation lesson is to place the admission decision at the actual scarce resource. For an ordered write pipeline, discuss an operator that waits for each asynchronous write to complete before starting the next. For parallel work, state a concurrency limit and identify any queues before and after it. Do not equate the number of threads with the number of accepted tasks.
When reviewing a custom emitter, ask what happens with zero demand, concurrent callbacks, cancellation, and a producer exception. Avoid an unbounded busy loop that spins until requests appear. That can occupy the same worker needed to propagate requests and prevent the stream from progressing.
What should happen when a producer cannot slow down?
The official BackpressureStrategy choices include buffering, dropping, keeping the latest value, and failing when the consumer cannot keep up. BUFFER retains all pending values; it is not a fixed memory ceiling. BackpressureStrategy API. The linked enum is from RxJava 3; check the exact overloads in your pinned dependency.
Suppose our original sensor emits readings A, B, C, D while downstream has no demand. A newest-value policy can eventually expose D, discarding intermediate history. A drop policy can discard arrivals during that period. A retain-everything policy needs space for all four, and eventually for every additional reading.
For the earlier rates, the backlog grows by approximately 9,900 readings per second while those rates remain constant. If each retained reading consumes an illustrative 200 bytes, the payload alone grows by roughly 1.98 MB per second. This estimate excludes queue nodes, object headers, references, and other operators. It is a capacity argument, not a measured RxJava benchmark.
Before proposing “just buffer,” name a maximum burst duration, such as two seconds and the failure behavior when that assumption breaks. For payments, durable admission and replay may be necessary. For UI progress updates, intermediate states might be expendable. For an audit log, dropping silently would change the product's correctness contract.
A revealing follow-up is whether switching an Observable to a Flowable repairs an earlier queue. Draw the conversion point. If an unbounded asynchronous queue already accepted the values upstream, a later demand boundary does not erase that accepted backlog. The policy must be located where uncontrolled production enters the pipeline.
What does observeOn change?
Official fact: RxJava 3 Observable.observeOn introduces asynchronous notification delivery with an unbounded buffer; its configurable buffer size describes allocation chunks, not a total capacity. Observable observeOn documentation.
That distinction makes this original review exercise useful:
Observable.range(1, 1_000_000)
.observeOn(Schedulers.single(), false, 16)
.subscribe(value -> slowRender(value), this::reportError);
“Sixteen” does not prove that at most sixteen values wait in this Observable pipeline. Ask whether rendering every intermediate value is necessary, how quickly the source produces, and where an explicit loss or admission policy belongs.
A Flowable asynchronous boundary instead participates in demand and prefetch. Review the selected operator's contract rather than transferring the Observable explanation unchanged. The official RxJava backpressure guide for version 2 and later explains why such boundaries can request ahead of the final consumer.
For interview practice, annotate a chain with the subscription thread, every notification boundary, each queue's capacity policy, and the resource used by each stage. Distinguish items queued for notification from requests already sent to a remote service. Lowering a queue size cannot cancel a request that another stage already issued.
An error is another ordering decision. Ask whether pending values should be processed before the failure or abandoned promptly. Explain the consequence for the application: showing older sensor readings after a failure is different from preserving successfully received records for an audit sink. State the intended behavior before selecting a delay-error overload.
What does subscribeOn fail to move?
subscribeOn schedules subscription work. It does not retroactively move a function that already ran while the chain was being assembled. RxJava's version-pinned RxJava 3.1.12 Flowable source documents subscription scheduling and sequential execution.
Compare these original sketches:
// loadValue() runs before just receives its argument.
Flowable.just(loadValue())
.subscribeOn(Schedulers.io());
// The callable is invoked when subscribed.
Flowable.fromCallable(() -> loadValue())
.subscribeOn(Schedulers.io());
The fix is meaningful only if loadValue is the work you intended to defer. If the method starts another asynchronous client internally, identify that client's own executor and cancellation API. A scheduler on the outer chain does not establish ownership of every task spawned inside it.
Also separate asynchronous execution from parallel execution. One worker processing notifications sequentially can keep the caller responsive while remaining a throughput bottleneck. If the task needs parallelism, discuss independence, order, concurrency limits, and how failures from multiple tasks are combined, so a failed task cannot disappear behind a successful callback.
For practice, narrate the chain from assembly to subscription to emission. That catches an eager call before you discuss pool sizing. It also lets an interviewer change the source from a callable to a callback without invalidating your entire explanation.
Does disposal mean all work has stopped?
Official fact: cancellation need not affect an asynchronous publisher immediately. RxJava's doFinally runs once per subscription after termination or cancellation, including a race between those events. It has no default scheduler. Reactive Streams cancellation rules, Flowable doFinally documentation.
Consider this original timeline: a callback begins writing order R17; the owner disposes; the write commits; a completion callback attempts to report success. Disposal does not undo R17. If a retry can write it again, idempotency belongs at the durable write boundary.
Use separate observations for “cancellation requested,” “subscription cleanup invoked,” “external client canceled,” and “running operation finished.” A cleanup counter alone cannot prove that a network request or another thread exited. A stale-result guard protects presentation state but does not reverse an external side effect.
Ownership matters too. CompositeDisposable.clear disposes its current contents while leaving the container reusable; dispose ends the container's lifetime, and later additions are disposed. Removing an entry and disposing it also differs from deleting the entry without disposal. These are official CompositeDisposable behaviors.
In an interview, associate the container with a concrete owner such as a screen instance or request scope. Ask who replaces it after that owner dies. An application-wide container can retain subscriptions far longer than a screen should.
For verification, use a controllable source and scheduler rather than sleeping for a guessed duration. Test zero initial demand, replenishment, cancellation before subscription, cancellation during work, and a terminal-event race. These are proposed tests; this guide does not claim a JVM integration run. Separately observe the external client's cancellation and the durable outcome.
How would you debug a stalled pipeline?
Use an original incident with three counters: callbacks received, values delivered after the scheduler boundary, and writes completed. If callbacks grow while deliveries stop, inspect queued notifications and worker availability. If deliveries grow while completions stop, inspect the external client's outstanding requests. If all three stop, inspect source connectivity and whether demand reached the producer.
Add timestamps for subscription, first request, first value, cancellation, and resource closure. Include a subscription identifier so overlapping screen instances do not appear to be one stream. Avoid printing sensitive payloads merely to establish timing.
A useful debugging answer chooses one discriminating experiment. Replace the slow write with a controlled completion and observe whether demand resumes. Replace the hot callback source with a finite demand-aware source and check whether the queue still grows. These substitutions narrow the boundary under investigation; they do not prove that the original external system obeys the same contract.
Finish by stating what would falsify your explanation. If completed writes keep increasing after disposal, cancellation did not stop all side effects. If memory grows with a quiet source, look for retained owners or replay state rather than blaming production rate.