Reactive programming is not simply about composing asynchronous operations. Understanding execution boundaries, maintaining contextual information, and controlling failure propagation are what make a reactive application predictable and maintainable.

1. Introduction: Beyond Mono and Flux

Consider a Spring WebFlux payment service. A request arrives, the service loads customer information, calls an external payment gateway, and persists the transaction. The pipeline is a handful of chained operators, and in development it behaves exactly as expected.

Production is less forgiving. A legacy client quietly blocks an event-loop thread, and throughput collapses under moderate load. A correlation ID that was present in the controller is null in the log line three operators later. An exception you expected to be handled propagates straight to the client. A retry fires, and the customer is charged twice. When you finally open the stack trace, it points into Reactor internals and says nothing about your code.

All of these symptoms trace back to three questions that reactive code forces you to answer explicitly:

  1. Where does this work execute? (Schedulers)
  2. How does request-scoped state travel with the pipeline? (Context)
  3. What happens when something fails? (Error handling, retry, timeout)

Reactor has solid mechanisms for each, but their semantics differ from what imperative Java trained us to expect. This article walks through them using a single running example, a payment processing service built on Java 21, Spring Boot 3.x, and Project Reactor.

2. Understanding Reactor's Execution Model

Before schedulers make sense, you need a clear model of how a Reactor pipeline actually runs.

A Mono or Flux is a Publisher. Calling an operator such as map or doOnNext does not process data. It wraps the upstream publisher in a new publisher that remembers what it should do. This is assembly time, and it produces nothing but a description of the pipeline. Nothing executes until someone subscribes.

Mono<String> result = Mono.just("Hello") .map(value -> value + " Reactor") .doOnNext(System.out::println); System.out.println("Pipeline assembled"); result.subscribe(); 

The output is Pipeline assembled followed by Hello Reactor. The doOnNext callback ran only after subscribe(). This laziness is why a pipeline can be built once and subscribed many times, and it is also the root of the retry behavior we'll examine later.

At subscription time, the flow reverses. The final Subscriber subscribes to the last operator, which subscribes to the one before it, and so on up to the source. Each hop passes a Subscription downstream through onSubscribe. The subscriber then signals demand with request(n), and only then does the source emit. Data flows downstream as onNext signals, and the sequence ends with exactly one terminal signal: onComplete or onError. An error is terminal. After onError, no further elements are emitted, which matters a great deal for how recovery operators behave.

There are three different threads you might mean when you say "the thread this runs on":

  • The thread where subscription happens, which is the thread that calls subscribe(), or where the subscription request is dispatched.
  • The thread where the source emits, which is often, but not always, the same.
  • The thread where each downstream signal is delivered, which can change at every scheduler boundary.

By default Reactor is concurrency-agnostic. If nothing introduces a scheduler, signals are delivered on whichever thread subscribed or produced the data, such as a Netty event-loop thread in WebFlux. subscribeOn and publishOn are the operators that change this, and the distinction above is exactly the distinction between them.

3. Reactor Schedulers: Controlling Where Work Executes

3.1 Understanding Reactor Schedulers

A Scheduler abstracts a unit of execution, much like an ExecutorService, with Reactor-specific features such as worker semantics and disposal. The built-in factory methods cover most needs:

Scheduler Purpose
Schedulers.immediate() Executes tasks on the current thread, with no switching
Schedulers.single() One reusable dedicated thread, sequential execution
Schedulers.parallel() Fixed pool sized to the CPU core count, for CPU-bound work
Schedulers.boundedElastic() Bounded, growable pool for blocking or long-running work
Schedulers.fromExecutorService(...) Wraps an executor you supply

The choice follows the nature of the workload. CPU-bound work, such as hashing, serialization, or computation, benefits from a pool sized to the number of cores, because more threads than cores only adds context switching. I/O-bound or blocking work spends most of its time waiting, so it needs enough threads to keep other tasks moving while some are parked. parallel() has exactly one worker per core, so a handful of blocking calls can occupy all of them and starve unrelated work.

boundedElastic() exists for that second category. It creates threads on demand up to a cap (by default ten times the number of CPU cores), queues tasks beyond that, and reclaims idle threads after a time-to-live. The bound is deliberate: an unbounded elastic pool turns a slow dependency into an out-of-memory incident.

3.2 subscribeOn vs publishOn

These two operators are the most frequently confused in Reactor, and the confusion usually comes from reading them as "run this operator on that thread".

subscribeOn controls the thread on which the subscription is performed, which in practice determines where the source begins to produce its data. Its influence travels upstream:

Mono<String> result = Mono.fromCallable(() -> { System.out.println(Thread.currentThread().getName()); return "Database result"; }) .subscribeOn(Schedulers.boundedElastic()); 

The callable runs on a boundedElastic thread, because the subscription (and therefore the call to the source) was moved there. Where the subscribeOn appears in the chain does not matter for the source's execution; its effect is on the upstream side. Note also that some sources emit on threads they control themselves, such as an HTTP client's event loop, so subscribeOn only affects the part that was actually scheduled by the subscriber.

publishOn changes the thread used for signals delivered downstream of its position:

Mono.just("Customer") .map(this::validate) // runs on the original thread .publishOn(Schedulers.parallel()) .map(this::transform) // runs on a parallel thread .subscribe(); 

validate runs wherever the pipeline was subscribed or emitted. transform runs on a parallel worker. Because publishOn introduces a queue and a handoff between threads, each boundary has a real cost, in both latency and memory behavior.

3.3 Common Scheduler Misconceptions

Multiple subscribeOn operators. Only the one closest to the source determines where the source's subscription executes. Later ones are effectively redundant for that purpose, so stacking them does not give you any additional control, and it hints at confusion in the code.

Multiple publishOn operators. Each one is a new boundary. Everything after it runs on its scheduler until the next publishOn. Sprinkling them throughout a pipeline "just in case" adds scheduling overhead and makes thread behavior harder to reason about.

parallel() is not for blocking I/O. It has a fixed worker count equal to the core count. Blocking there does not just slow that one call, it removes capacity from every pipeline sharing the pool.

boundedElastic() is not a universal escape hatch. It is the right place to isolate unavoidable blocking calls, but if most of your pipeline lands there, you have rebuilt a thread-per-request model with extra steps and lost the benefits of reactive I/O. Treat it as a quarantine zone, not a default.

Scheduler lifecycle. The shared schedulers (parallel(), boundedElastic(), single()) are managed for you. If you create your own with Schedulers.newBoundedElastic(...) or fromExecutorService(...), you own its disposal. A leaked scheduler leaks threads.

On Java 21 there is an interesting option: Reactor 3.6 and later can back boundedElastic() with virtual threads, enabled by the reactor.schedulers.defaultBoundedElasticOnVirtualThreads system property. This makes blocking isolation cheaper, but it does not make the blocking call itself any less blocking for the caller's logical flow, so the architectural reasoning in this section still applies.

3.4 Practical Example: Isolating Blocking Dependencies

Our payment service must consult a legacy fraud-check client that exposes only a synchronous API:

Mono<FraudVerdict> verdict = Mono.fromCallable(() -> legacyFraudClient.check(request.customerId(), request.amount()) ) .subscribeOn(Schedulers.boundedElastic()); 

The blocking call now executes on a boundedElastic thread. The Netty event-loop thread that received the HTTP request is released immediately and can keep serving other connections. Every other call in the pipeline, such as the WebClient request to the payment gateway, remains non-blocking and needs no scheduler at all.

A crucial limitation must be stated plainly: schedulers control where work executes. They do not make blocking operations non-blocking. A JDBC call on boundedElastic is still a blocking JDBC call that occupies a thread for its duration. You have moved the cost somewhere it can be contained and measured, which is valuable, but you have not removed it. If that dependency is on your critical path at scale, the real fix is a non-blocking driver such as R2DBC or a different integration design.

Engineering takeaway: scheduler selection is an architectural decision about isolation and capacity, not merely a performance optimization.

Subscription flows UP Signals flow DOWN (subscribeOn moves where this starts) (publishOn moves where this continues) subscriber -> map -> publishOn -> map -> subscribeOn -> source | | downstream runs on source starts on scheduler B scheduler A 

4. Context Propagation: Managing State Across Asynchronous Boundaries

In a classic Spring MVC application, one request is handled by one thread from start to finish. That makes ThreadLocal a natural tool, and we use it constantly: correlation IDs, the SecurityContext, tenant identifiers, logging MDC, transaction state.

A reactive pipeline breaks the assumption underneath all of that. One request may begin on a Netty thread, continue on a boundedElastic worker, and complete back on an event-loop thread, and a different request will interleave on the same threads. A value stored in a ThreadLocal is attached to a thread, not to a request, so it either vanishes at a thread switch or, worse, leaks into an unrelated request.

4.1 Understanding Reactor Context

Reactor's answer is the Context, an immutable key-value structure associated with the subscriber rather than any thread. It travels up the subscription chain, which means operators can read it regardless of which thread they execute on. Because it is immutable, writing to it creates a new Context; that is what contextWrite does.

Mono<String> result = Mono.deferContextual(ctx -> { String correlationId = ctx.get("correlationId"); return Mono.just(correlationId); }) .contextWrite(ctx -> ctx.put("correlationId", "req-12345")); 

Position matters, and it is counterintuitive at first. Because context flows from the subscriber upstream, a contextWrite is visible to operators above it in the chain, not below. The reader (deferContextual) sits upstream of the writer here, so it sees the value. Reversing their order would leave the reader without the key.

4.2 Context Propagation Across Thread Switching

The decisive property is that the context is unaffected by scheduler hops:

Mono<String> result = Mono.deferContextual(ctx -> Mono.just(ctx.get("correlationId")) ) .publishOn(Schedulers.parallel()) .map(id -> "Request: " + id) .contextWrite(ctx -> ctx.put("correlationId", "req-12345")); 

The value read in deferContextual is the same one that was written, even though downstream operators now run on a different thread. With a ThreadLocal, the map after publishOn would be on a thread that never saw the value.

In our payment service, the natural place to establish context is a WebFilter at the edge:

@Component class CorrelationIdFilter implements WebFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { String correlationId = Optional .ofNullable(exchange.getRequest().getHeaders().getFirst("X-Correlation-Id")) .orElseGet(() -> UUID.randomUUID().toString()); return chain.filter(exchange) .contextWrite(ctx -> ctx.put("correlationId", correlationId)); } } 

Anything downstream in the same subscription, from the controller to the service to the gateway client, can now read correlationId with deferContextual.

4.3 Integrating MDC and Observability

Most of the Java ecosystem still reads from thread-locals: SLF4J's MDC, many tracing libraries, and security APIs. Reactor Context does not feed them automatically. MDC does not follow a thread switch unless something explicitly copies it.

The modern bridge is the Micrometer Context Propagation library. With io.micrometer:context-propagation on the classpath, you register ThreadLocalAccessors for the thread-locals you care about, and Reactor can capture values into its Context and restore them into thread-locals around operator execution. This is switched on with Hooks.enableAutomaticContextPropagation() (available since Reactor 3.5.3), and the contextCapture() operator marks where thread-local values should be captured into the context. Spring Boot 3.x's observation and tracing support builds on this machinery, which is how trace and span identifiers reach your log lines in a reactive stack.

Two cautions. First, this restoration is a bridge for imperative libraries, and it carries a runtime cost, so you should not use it as a reason to keep storing request state in thread-locals. Second, be precise about what is configured. "MDC just works in WebFlux" is only true when the accessor registration and hooks are in place; without them it silently does not.

Practical takeaway: use Reactor Context as the primary carrier of request-scoped state, and use the context propagation integrations only where you must bridge to thread-local-based libraries.

HTTP request -> CorrelationIdFilter: contextWrite(correlationId = "req-12345") | | (Context travels with the SUBSCRIPTION, not the thread) v Controller -> Service -> publishOn(parallel) -> WebClient call ^ ^ ^ ^ reads ctx reads ctx different reads ctx thread, same context 

5. Reactive Error Handling: Understanding Failure Semantics

The foundation is the terminal nature of errors. When a publisher signals onError, that sequence is over. Error operators therefore do not "continue" the sequence; they replace it, either with a fallback value, a different publisher, or a different error. Once you read the operators this way, their behavior becomes predictable.

Operator Purpose
onErrorReturn Replace the error with a fallback value
onErrorResume Switch to an alternative publisher
onErrorMap Translate one exception into another
doOnError Observe the error as a side effect, without recovering
doFinally Run on completion, error, or cancellation
onErrorComplete Convert an error into a normal completion

5.1 Recovering with Fallback Values

return inventoryClient.getStock(productId) .onErrorReturn(0); 

This is concise and, for some cases, entirely appropriate: a recommendation widget that shows nothing when the recommendation service is down is a perfectly reasonable degradation. The danger is in using it where a failure is information. A stock count of zero that was really a network failure causes the system to behave as though the item is sold out, and the incident is invisible in the data. Fallbacks to constants should be reserved for cases where a wrong answer is cheap, and ideally the original error should still be logged or counted. You can also narrow what gets swallowed, for instance onErrorReturn(WebClientRequestException.class, 0), so that unexpected failures still propagate.

5.2 Switching to an Alternative Publisher

return inventoryClient.getStock(productId) .onErrorResume(error -> cachedInventory.getStock(productId) ); 

onErrorResume is the more expressive tool, because the fallback is itself a publisher and can be asynchronous, such as a cache lookup, a read replica, or a secondary provider. This is where degraded-service strategies live. Two points deserve attention. The fallback can itself fail, and unless it is handled, that failure replaces the original error and obscures it. And a fallback that returns stale data should be explicit about it, for example by marking the response as degraded, rather than pretending nothing happened.

5.3 Transforming Exceptions

return paymentClient.process(payment) .onErrorMap(error -> new PaymentProcessingException("Payment processing failed", error) ); 

onErrorMap is exception translation at a service boundary. The gateway client throws transport-level exceptions; your domain layer should not leak those upward, and your API layer should not have to understand them. Wrapping with the cause preserved lets a @ControllerAdvice map PaymentProcessingException to a stable HTTP response while the original exception stays available for diagnostics.

5.4 Observability Without Recovery

return paymentClient.process(payment) .doOnError(error -> log.error("Payment processing failed", error) ); 

doOnError is a side-effect operator, nothing more. The error continues downstream unchanged. It is a frequent source of bugs for people who read it as "handle the error": the log line appears, and the request still fails. Use it for logging and metrics, and pair it with an actual recovery operator when recovery is what you want. For cleanup that must run however the pipeline ends, doFinally receives a SignalType (ON_COMPLETE, ON_ERROR, or CANCEL), and the cancellation case is one that try/catch in imperative code never had to consider.

payment gateway call | onError ----> onErrorMap (translate) ----> retry decision | | retryable not retryable | | resubscribe onErrorResume | (fallback) v | terminal outcome: success / failure / degraded 

6. Retry, Timeout, and Resilience Patterns

Error recovery is about what to do once. Resilience is about what to do when failures are transient and repeated. Both are easy to get dangerously wrong.

6.1 Retry Strategies

retry(n) resubscribes immediately, up to n times. That is rarely what you want against a struggling dependency, because it adds load at the precise moment the dependency is least able to bear it. retryWhen with a Retry spec gives you control:

return paymentClient.fetchStatus(paymentId) .retryWhen( Retry.backoff(3, Duration.ofMillis(200)) .maxBackoff(Duration.ofSeconds(2)) .filter(this::isTransientFailure) ); 

This retries at most three times with exponential backoff starting at 200 ms and capped at two seconds. Retry.backoff applies jitter by default (a factor of 0.5), which spreads retries from many clients over time rather than letting them synchronize. The filter is what makes this safe: a 503 or a connection reset is worth retrying, while a 400 or a validation failure will fail identically every time. One behavior to know: when retries are exhausted, Reactor by default throws a Retry.RetryExhaustedException wrapping the last failure. If downstream code expects the original exception, configure onRetryExhaustedThrow to propagate it.

6.2 Timeout Management

return paymentClient.fetchStatus(paymentId) .timeout(Duration.ofSeconds(3)); 

If no signal arrives within the window, the pipeline fails with a TimeoutException. Two details matter in practice.

Placement. A timeout placed before retryWhen bounds each attempt. Placed after it, it bounds the total time across all attempts. These are different guarantees, and you usually want both: a short per-attempt timeout and a longer overall deadline.

A timeout is not cancellation of remote work. When the timeout fires, Reactor cancels the upstream subscription, which with WebClient closes the connection or abandons the exchange. That stops your side from waiting. It does not guarantee the remote service has stopped processing, and for a payment request it may well have completed. This is the single most important thing to understand about timeouts on side-effecting calls.

6.3 Common Failure Scenarios

Retry storms. If every client in every service retries three times, a dependency under stress receives a multiple of its normal load. Limits, backoff, and jitter are mitigations, and circuit breakers (for example Resilience4j's Reactor integration) are the structural answer.

Retrying non-idempotent operations. This is the dangerous one, and it follows directly from the execution model in section 2: a retry resubscribes to the upstream publisher, and if that publisher performs a side effect, the effect happens again.

// Dangerous: a retry re-executes the charge paymentClient.charge(request) .retryWhen(Retry.backoff(3, Duration.ofMillis(200))); 

If the first charge succeeded but the response was lost to a timeout, the retry charges the customer a second time. The fix is not in Reactor but in the design: send an idempotency key with the request so the gateway can recognize a duplicate, and keep transaction boundaries clear so a retry does not repeat a partially committed unit of work.

Mono<PaymentResult> charge(PaymentRequest request) { return Mono.deferContextual(ctx -> { String idempotencyKey = request.idempotencyKey(); // stable across retries return paymentClient.charge(request, idempotencyKey); }) .timeout(Duration.ofSeconds(3)) // per attempt .retryWhen(Retry.backoff(2, Duration.ofMillis(200)) .filter(this::isTransientFailure)) .onErrorMap(e -> new PaymentProcessingException("Payment processing failed", e)); } 

The key is generated once, outside the retry scope, so every attempt carries the same one.

Fallback failures and cascades. A fallback that calls another remote service is another dependency that can fail, and chained fallbacks across microservices can propagate load backward through the call graph. Timeouts at every boundary are what keep a slow dependency from becoming a slow system.

7. Debugging and Testing Reactive Pipelines

Debugging

A reactive stack trace usually shows Reactor internals and the scheduler thread, because the code that assembled the pipeline is long gone from the call stack when an error occurs. There are several tools to recover that information:

  • checkpoint("description") adds a lightweight marker, so when an error flows through it, the trace includes where it passed. It is cheap enough to use at meaningful stages, such as after the gateway call.
  • Hooks.onOperatorDebug() captures an assembly-time stack for every operator. It is very informative and very expensive, which makes it suitable for local debugging but not for production.
  • ReactorDebugAgent.init() from reactor-tools provides assembly tracing via bytecode instrumentation without the runtime overhead of the hook, and is the better choice when you need it in a deployed environment.
  • BlockHound detects blocking calls on non-blocking threads and is excellent in tests and CI for catching the problem from section 1 before production.

Beyond tracing, learn to read thread names: reactor-http-nio-* means you are on an event-loop thread and must not block, while boundedElastic-* or parallel-* tell you which scheduler you are on. Watch for scheduler saturation, which shows up as growing latency and queued tasks on boundedElastic while CPU stays idle. Distributed tracing connects the picture across services, which is where the context propagation work in section 4 pays off.

Testing

StepVerifier subscribes to a publisher and lets you assert on each signal:

StepVerifier.create( Mono.just("Reactor").map(String::toUpperCase) ) .expectNext("REACTOR") .verifyComplete(); 

The same tool covers the harder cases. Error paths use expectError(PaymentProcessingException.class) or expectErrorSatisfies(...). Retry and timeout tests use StepVerifier.withVirtualTime(...), which controls a virtual clock so a three-second timeout does not make your test wait three seconds. Context-dependent code can be given an initial context through StepVerifierOptions.create().withInitialContext(...). Cancellation can be asserted with thenCancel().

For more advanced scenarios, TestPublisher lets you emit signals manually and simulate misbehaving sources, while PublisherProbe wraps a fallback publisher so you can assert whether it was subscribed, which is exactly what you need to verify that onErrorResume actually switched to the cache.

8. Common Mistakes and Production Anti-Patterns

Anti-pattern Consequence
Blocking on event-loop threads Reduced throughput and request starvation
Using parallel() for blocking I/O Scheduler starvation across unrelated pipelines
Treating Context as ThreadLocal Wrong placement of contextWrite, missing values
Unbounded or unfiltered retries Retry storms and cascading failures
Swallowing errors with constant fallbacks Hidden operational problems and wrong data
Excessive publishOn switching Extra scheduling overhead and harder reasoning
Missing timeout boundaries Requests that hang and exhaust resources
Retrying non-idempotent operations Duplicate side effects, such as double charges
Assuming a timeout stops remote work Remote operation completes after the caller gave up

Most of these share a cause: carrying imperative assumptions into a model where threads, state, and failures behave differently.

9. Final Thoughts

Three principles carry most of what matters:

  • Schedulers control execution boundaries. They decide where work runs, they do not make blocking calls non-blocking, and choosing one is an architectural decision about isolation.
  • Reactor Context preserves subscriber-associated information. It follows the subscription, not the thread, and bridging to thread-local libraries should be a deliberate integration rather than an assumption.
  • Error operators define how failures propagate and recover. Errors are terminal signals, recovery replaces the sequence, and retries resubscribe, so every retry is a question about idempotency.

Writing reactive code is relatively easy. Building predictable reactive systems requires understanding where execution happens, how context travels, and what happens when things fail.