cswork / libraryOpen practice workspace

Articles

Spring WebFlux Interview Questions: Backpressure, Blocking Calls, and Reactor Context

Prepare Spring WebFlux interview questions with blocking-call repairs, backpressure limits, Reactor Context examples, and focused cancellation and demand tests.

A controller returns Mono<Order>, but unrelated requests stall whenever its database call slows down. The return type looks reactive; the work is not. A useful Spring WebFlux interview answer starts by asking when the database method executes and which thread waits for it.

The same discipline applies to backpressure and Reactor Context. Demand controls a publisher boundary, not every request arriving at the service. Context belongs to a subscription path, not whichever thread happens to run a callback. Trace execution, demand and context separately before proposing an operator.

This article concentrates on a blocking-call repair, a bounded fan-out calculation and a context-placement puzzle.

Does returning Mono make an endpoint nonblocking?

No. Mono describes a publisher with zero or one result; it does not inspect arbitrary Java code and convert blocking I/O into nonblocking I/O. The Spring WebFlux overview describes the reactive web stack, while its concurrency discussion explains why blocking work is problematic for event-loop execution.

Consider this illustrative controller fragment:

return Mono.just(legacyDao.load(id));

Java evaluates legacyDao.load(id) before calling Mono.just. If the controller is executing on an event-loop worker, that worker waits for the blocking DAO. Appending a scheduler operator afterward cannot move work that already happened.

Now compare a deferred source:

return Mono.fromCallable(() -> legacyDao.load(id));

This defers the call until subscription, but deferral alone does not choose a safe worker. “Lazy” and “nonblocking” are different properties. The Reactor FAQ recommends wrapping unavoidable synchronous blocking work with fromCallable and subscribeOn(Schedulers.boundedElastic()).

return Mono.fromCallable(() -> legacyDao.load(id))
    .subscribeOn(Schedulers.boundedElastic());

This is an isolation pattern, not a conversion of JDBC into a nonblocking driver. The DAO still waits on a thread. These snippets are explanatory examples, not compiled application or performance-test results. The references were checked against Spring Framework 7.0.9 and Reactor 3.8.7 documentation on September 21, 2026.

What changes after moving the blocking call?

The event-loop worker can stop waiting for that DAO call, but the workload still consumes scheduler capacity and a database connection. If those resources saturate, waiting has moved rather than disappeared. Inspect queueing, connection acquisition, call duration and cancellation separately.

FormWhen the DAO is invokedWhat remains to verify
Mono.just(load())While constructing the publisherThe caller can already be blocked
Mono.fromCallable(() -> load())At subscriptionWhich thread performs the subscription work
Callable plus bounded-elastic subscribeOnAt subscription on the selected schedulerQueue capacity, database limits and deadlines

Do not assume every blocking API responds promptly to interruption. A timeout can terminate the reactive result while the underlying operation continues for some time. Configure the driver's own limits and test what happens when the caller disconnects or cancels.

If the endpoint mainly uses blocking persistence and simple synchronous logic, discuss Spring MVC as a legitimate alternative. The Spring overview explicitly treats framework choice as a dependency and workload decision. “WebFlux is always faster” is weaker than explaining the measured benefit you expect and the complexity you accept.

How do publishOn and subscribeOn differ?

The scheduler reference distinguishes switching downstream signal processing with publishOn from scheduling subscription work with subscribeOn. Neither operator means “make all work parallel.”

For a synchronous source, placing subscribeOn near the source makes its execution policy clear. A later publishOn can then select where subsequent callbacks run. Operators with their own scheduling behavior require their own trace; memorizing “everything runs on this scheduler” is not enough.

In the DAO example, identify the exact call site before moving anything. A publishOn after a blocking map does not retroactively protect the worker used by that map. Conversely, switching to a worker intended for CPU work does not make it an appropriate home for long blocking I/O.

Prefer a small annotated trace: subscription, database invocation, returned value, mapping callback, response write. In a real diagnostic test, record thread names at those points and compare them with the configured runtime. Avoid promising a particular thread name independent of the server and scheduler setup.

Does backpressure limit the service to eight requests?

No. Reactor propagates demand upstream through requests. Operators can reshape those requests. limitRate changes request batching; it is not an eight-requests-per-second limiter or a service-wide admission controller.

Consider a hypothetical endpoint that receives IDs and looks up each order through a nonblocking client:

return ids.flatMap(id -> client.fetchOrder(id), 8);

The Flux API defines the concurrency parameter in terms of subscribed inner publishers. Assuming each inner publisher represents one active remote call, this bounds that fan-out for one subscription. It does not bound all subscriptions in the service.

If 20 HTTP requests each have enough IDs to occupy eight slots, they can collectively demand up to 160 concurrent remote operations. A connection pool capped at 40 may instead leave work waiting for connections. The numbers are a capacity thought experiment, not observed throughput; actual concurrency also depends on demand, timing and client behavior.

Now assume each active call takes half a second. Eight continuously occupied slots suggest about 16 completions per second per subscription in a steady, idealized case. That is an arithmetic relationship, not a configured rate limit. Shorter calls can complete faster, while retries and queue waits change the result.

Explain the global budget separately: accepted requests, shared dependency concurrency, pending acquisitions, per-request fan-out and deadline. A bound at one layer should not conceal an unbounded queue at another.

When should you buffer, drop or fail?

If a producer cannot slow down, the application needs an explicit overload policy. A buffer trades memory and latency for temporary absorption; it does not increase the downstream service rate. A growing queue is unfinished work with a cost.

Suppose a telemetry preview can discard intermediate samples because only the latest display value matters. A drop or latest-value policy may be reasonable there. It is not reasonable to silently drop an order mutation just because a downstream subscriber is slow. Tie the policy to the meaning of an item.

Bound retained bytes as well as item count when payload sizes vary. Ten large documents and ten small IDs have different memory footprints. Include serialization buffers, prefetched items and any later collectList in the accounting; returning a Flux does not guarantee that the whole request is streamed without accumulation.

A strong answer names what happens when the bound is reached: reject, fail, expire, persist elsewhere or deliberately drop a permitted item. Then state the observable signal. “We use backpressure” is incomplete without that overload outcome.

Where can a Reactor Context value be read?

The Reactor Context reference describes immutable context associated with subscribers. contextWrite makes a derived context available upstream along that subscription path. Read it using context-aware operators such as deferContextual, not an assumption about the current thread.

Trace this example from the subscriber toward the source:

Mono<String> visible = Mono.just("order")
    .flatMap(value -> Mono.deferContextual(ctx ->
        Mono.just(value + ":" + ctx.getOrDefault("traceId", "missing"))))
    .contextWrite(ctx -> ctx.put("traceId", "A7"));

The predicted value is order:A7. The final contextWrite is downstream of the reader, so the context it derives is available to that upstream work. Now move the write:

Mono<String> misplaced = Mono.just("order")
    .contextWrite(ctx -> ctx.put("traceId", "A7"))
    .flatMap(value -> Mono.deferContextual(ctx ->
        Mono.just(value + ":" + ctx.getOrDefault("traceId", "missing"))));

With no initial traceId, the predicted value is order:missing. The write affects its upstream source, not the downstream reader introduced by flatMap. These predictions follow the documented placement rules; they are not reported local execution results.

Treat Context as a propagation mechanism, not a mutable global map. A write inside one inner sequence does not automatically update sibling sequences or the outer chain. Keep request-specific metadata scoped so two subscriptions cannot accidentally share an application's mutable holder.

Is ThreadLocal always incompatible with Reactor?

An ordinary ThreadLocal does not automatically follow a logical request across scheduler switches, and a reused thread can serve unrelated requests. That makes naive correlation-ID handling unreliable. It does not mean integration with ThreadLocal-based libraries is impossible.

Reactor's context-propagation support describes Micrometer integration, registered accessors, limited restoration and an automatic mode. The behavior depends on the installed integration and configuration. An application using that support is different from a bare ThreadLocal assignment.

In an interview, explain both the propagation rule and the cleanup requirement. Test concurrent requests with distinct IDs, including an error and cancellation path, and verify that no later request inherits an earlier ID. Do not treat an inbound correlation ID as proof of authenticated identity; logging metadata and authorization have different trust boundaries.

How would you test the answer instead of trusting the operators?

The Reactor testing guide covers StepVerifier, virtual time and context expectations. Separate operator tests from actual server and dependency tests. Neither replaces the other.

For demand, create a verifier with initial request zero, request a small number of values, assert them and then cancel. A demand-aware test source can reveal unwanted upstream requests. For fan-out, instrument a fake dependency's active-call count and assert the maximum within a single subscription; then run multiple subscriptions to test the shared budget separately.

For the context puzzle, assert both predicted strings and add a case with an initial context value. For timeout behavior, create timed publishers inside the virtual-time supplier so the test's scheduler controls them. Keep a finite verification timeout so a stalled test fails rather than waiting indefinitely.

Finally, exercise the real HTTP boundary with a slow dependency. Observe event-loop responsiveness, pending connections, error mapping, cancellation and whether upstream work actually stops. Never infer cancellation of a database side effect merely from the client receiving a timeout.

Practice questionWebFlux-focused follow-up
Debug a slow concurrent network server under loadLocate blocking and queueing before changing concurrency.
Build a Custom CompletableFuture: Async Primitive and Parallel Array ProcessingSeparate completion, scheduling and parallel work.
Implement sequential and parallel URL requestsBound fan-out and explain cancellation.
Design a Thread-Safe Bounded Blocking QueueCompare queue capacity with reactive demand.
Debug an Asynchronous SDK Race Condition with a CustomerReproduce a lifecycle failure with observable evidence.

Return to the Spring Boot interview guide to connect the reactive path with application configuration. Before ending an answer, identify the worker that performs the work, the bound that protects the dependency and the subscriber path carrying request context.

Sources and Further Reading