publishOn vs subscribeOn, and the schedulers

What it is

In Project Reactor, a pipeline declares what happens; these two operators declare where it happens. They are the only two ways to move work between threads, and they work in different directions:

subscribeOn(scheduler) changes the thread on which the subscription happens, which means it changes where the source does its work. Its effect flows upward to the origin of the chain.

publishOn(scheduler) changes the thread on which subsequent operators run, by moving elements into that scheduler's queue as they pass through. Its effect flows downward from where it appears.

Two consequences follow from those definitions and they explain nearly every confusing behaviour people report:

  • subscribeOn position does not matter. Anywhere in the chain, it affects the source. Two subscribeOn calls: the one closest to the source wins, and the other does nothing.
  • publishOn position matters entirely. It affects operators after it, so multiple publishOn calls each take effect for their own segment.

What they are confused with: neither makes blocking code non-blocking. Moving a blocking call to boundedElastic means it blocks a thread from a different pool instead of the event loop, which is a containment strategy, not a fix. It is the right containment strategy, and it is worth being precise that it does not change the call's nature.

The problem it solves

Reactor's default is that everything runs on the thread that called subscribe(), and stays there. That default is usually right: no context switching, no queue handoff, no cache-line bouncing between cores. Reactor is deliberately single-threaded-by-default and concurrency is opt-in.

Two situations break that default.

The event loop must not block. In WebFlux, request handling runs on a small number of Netty event-loop threads, typically one per core. A blocking call on one of those threads stops every other connection assigned to it. With 8 event loops on an 8-core machine and 10,000 concurrent connections, blocking one thread for 200 milliseconds stalls roughly 1,250 connections for 200 milliseconds. This failure mode has its own page: never block the event loop.

Different work suits different threads. A CPU-bound transform wants a small pool sized to core count, because more threads than cores just adds context switching. A blocking JDBC call wants a large pool, because those threads are idle waiting on I/O and you want many of them in flight. Putting both on the same pool means one starves the other.

Mechanics

The schedulers, and which to use

SchedulerThreadsUse for
Schedulers.parallel()Fixed, = CPU coresCPU-bound work: transforms, parsing, computation
Schedulers.boundedElastic()Elastic, capped at 10 x cores by default, queue boundedBlocking calls: JDBC, blocking HTTP clients, file I/O
Schedulers.single()OneSerialising work that must not run concurrently
Schedulers.immediate()Caller'sExplicitly "do not switch"
Schedulers.fromExecutor(e)YoursIntegrating an existing pool with its own sizing

The two that matter are parallel and boundedElastic, and the rule is simple: parallel for CPU, boundedElastic for blocking. Putting a blocking call on parallel is the second-worst thing you can do after putting it on the event loop, because parallel has exactly one thread per core and blocking one of them removes a core's worth of capacity from every pipeline in the process.

boundedElastic is bounded on purpose. The old elastic() was unbounded and was deprecated because unbounded thread creation under load is a way to die; the bounded version has a thread cap (default 10 * cores) and a task queue cap (default 100,000), and rejects with RejectedExecutionException beyond that. That rejection is a feature: it is backpressure on your blocking calls, surfaced as an error rather than as an OOM.

subscribeOn: position-independent, affects the source

Flux.fromIterable(loadFromDisk())     // blocking source
    .map(this::parse)
    .filter(this::isValid)
    .subscribeOn(Schedulers.boundedElastic())   // position irrelevant
    .subscribe(this::handle);

Here loadFromDisk() and everything downstream of it runs on a boundedElastic thread, because the subscription happened there and nothing subsequently changed the thread. Moving subscribeOn to immediately after fromIterable produces identical behaviour.

The classic gotcha, worth having ready:

Flux.fromIterable(loadFromDisk())    // <- runs on the CALLING thread, right now

loadFromDisk() is a plain Java method call evaluated when the pipeline is assembled, before any subscription exists. subscribeOn cannot help, because the work already happened. The fix is deferral:

Flux.defer(() -> Flux.fromIterable(loadFromDisk()))   // now it runs at subscribe time
    .subscribeOn(Schedulers.boundedElastic());

This is the single most common subscribeOn bug: assembly time versus subscription time. Any eagerly-evaluated expression inside a pipeline runs on the assembling thread regardless of every scheduler operator in the chain.

publishOn: position-dependent, affects downstream

Flux.range(1, 1000)                        // main thread
    .map(this::cheapTransform)             // main thread
    .publishOn(Schedulers.parallel())
    .map(this::expensiveComputation)       // parallel-1
    .publishOn(Schedulers.boundedElastic())
    .map(this::blockingDatabaseLookup)     // boundedElastic-1
    .subscribe(this::handle);              // boundedElastic-1

Three segments, three threads, each operator running where the nearest preceding publishOn put it. This is the pattern for a mixed pipeline and it is what you want when a chain has genuinely different kinds of work in it.

publishOn carries a prefetch (default 256): it requests that many elements from upstream to keep its queue full, replenishing when 75 percent are consumed. That queue is where the memory goes, so the prefetch is the knob when a pipeline holds too much in flight. See Reactive Streams backpressure for the arithmetic.

Both together

Mono.fromCallable(() -> jdbcTemplate.queryForObject(sql, Long.class))  // blocking
    .subscribeOn(Schedulers.boundedElastic())    // the JDBC call runs here
    .map(this::enrich)                           // still boundedElastic
    .publishOn(Schedulers.parallel())
    .map(this::computeScore)                     // now parallel
    .subscribe();

Mono.fromCallable is the correct wrapper for a blocking call, because the lambda is evaluated at subscription time rather than assembly time, which is exactly the deferral the Flux.defer example needed.

What the schedulers do to context

A thread switch loses ThreadLocal. Anything relying on it (MDC logging context, Spring Security's SecurityContextHolder, an OpenTelemetry span, a TransactionSynchronizationManager) is gone after publishOn or subscribeOn. This is not a Reactor defect; it is what "different thread" means.

Reactor's answer is the Context, a subscriber-scoped immutable map that propagates upstream through the chain:

Mono.deferContextual(ctx -> {
        String traceId = ctx.get("traceId");
        return service.call(traceId);
    })
    .contextWrite(Context.of("traceId", incomingTraceId));   // written DOWNSTREAM of use

The propagation direction is the surprise: contextWrite affects operators above it in the chain, because the context travels with the subscription, which flows upward. Reading the code top to bottom, the write appears after the read. Since Reactor 3.5 the context-propagation library bridges this to ThreadLocal automatically for libraries that need it, which removes most of the manual work but not the need to understand why it was necessary.

A worked example: a WebFlux service with 3 percent of its throughput

An inventory API on WebFlux, 4-core containers, 8 Netty event loops. It called a legacy SOAP service through a blocking JAX-WS client, because that client was the only one the vendor supplied.

@GetMapping("/inventory/{sku}")
public Mono<Inventory> get(@PathVariable String sku) {
    return Mono.just(sku)
        .map(s -> legacySoapClient.getStock(s))    // BLOCKING, on the event loop
        .map(this::toInventory);
}

The SOAP call averaged 180 ms. Load test results were baffling to the team:

concurrency    throughput      p99 latency
     10           42 rps         245ms
     50           43 rps        1,180ms
    200           41 rps        4,900ms
   1000           38 rps       26,000ms

Throughput flat at about 42 requests per second regardless of concurrency, with latency growing linearly. That signature (flat throughput, linear latency) is a queue in front of a fixed number of servers, and Little's Law names them: at 42 rps and 180 ms of service time, the number of busy servers is 42 * 0.18 = 7.6, which is 8 event loop threads. The entire service's concurrency was 8, because every request occupied an event loop for the duration of the SOAP call.

Fix 1: get the blocking call off the event loop.

return Mono.fromCallable(() -> legacySoapClient.getStock(sku))
    .subscribeOn(Schedulers.boundedElastic())
    .map(this::toInventory);
concurrency    throughput      p99 latency
     10           55 rps         190ms
     50          210 rps         260ms
    200          218 rps       1,100ms
   1000          216 rps       5,200ms

Throughput went from 42 to about 216 rps, a 5.1x improvement. The new ceiling is boundedElastic's default cap of 10 * cores = 40 threads: 40 / 0.18 = 222 rps, matching the measurement.

Fix 2: size the pool for the actual workload. These threads are blocked on network I/O, not computing, so the CPU-core-based default is the wrong sizing model. They sized it from the target: 400 rps at 180 ms of service time needs 400 * 0.18 = 72 concurrent calls, plus headroom.

// Dedicated, explicitly sized, named for thread dumps. Not the shared default.
private final Scheduler soapScheduler = Schedulers.newBoundedElastic(
    100,          // threads: 72 needed + headroom
    500,          // queued tasks before rejection
    "soap-client",
    60,           // idle thread TTL, seconds
    true);        // daemon
concurrency    throughput      p99 latency
     50          270 rps         185ms
    200          540 rps         370ms
   1000          548 rps       1,800ms

Fix 3: bound the downstream, because 548 rps broke the SOAP service. The legacy system had a documented limit of roughly 300 concurrent connections, and the new pool was exceeding it, producing errors at the vendor. This is the failure mode of fixing a bottleneck: you move the load to the next thing, which may be less able to take it. They added a concurrency limit and a circuit breaker:

.flatMap(sku -> callSoap(sku), 250)     // max 250 in flight, matching the vendor's limit

Final state:

                       original    after fix 1   after fix 2   final
throughput (rps)          42           216           548        512
p99 @ 200 concurrent   4,900ms        1,100ms        370ms      395ms
event loop threads
  blocked                8/8           0/8           0/8        0/8
errors at vendor          0             0          ~4%/min       0

512 rps against 42, a 12x improvement, from two operators and a pool size. The number that explains all of it is the first one: 8 concurrent requests, because the event loop count was the concurrency limit. Little's Law found it in one line, and that arithmetic is the thing worth carrying out of this example.

The final small regression (548 to 512 rps) was accepted deliberately: it is the cost of not overwhelming the vendor, and it removed a 4-percent error rate. Trading 6 percent of throughput for zero errors is not a close call.

Production evidence

Reactor's own documentation states the distinction explicitly and calls subscribeOn's position-independence out as a common confusion, which is unusual for reference documentation and reflects how often it comes up in issues.

Schedulers.elastic() was deprecated in Reactor 3.4 in favour of boundedElastic(), with the stated reason that unbounded thread creation under load is a failure mode rather than elasticity. That deprecation is a concrete instance of the "bounded queues and bounded pools" principle, applied by the framework authors to their own default.

Spring Boot's WebFlux actuator exposes event-loop metrics (reactor.netty.eventloop gauges), and BlockHound exists as a dedicated JVM agent to detect blocking calls on non-blocking threads at runtime. The existence of a purpose-built agent for one bug class is evidence about how common that bug is.

Netflix, which pioneered reactive on the JVM through RxJava, publishes on the operational cost, and their guidance consistently separates blocking integrations onto dedicated pools rather than shared ones, for the isolation reason: a dedicated pool means a slow dependency cannot exhaust the capacity other dependencies need. That is the bulkhead pattern expressed as scheduler choice.

R2DBC exists specifically to avoid this whole problem for databases, by providing a genuinely non-blocking driver rather than a blocking one on a scheduler. Where R2DBC is available, it is better than JDBC on boundedElastic, because it needs no thread per in-flight query at all.

The debate

Is boundedElastic a fix or a workaround? A containment strategy, and the distinction matters. The blocking call still occupies a thread for its duration; you have moved the cost from an 8-thread pool where it is catastrophic to a 40-thread pool where it is survivable. The genuine fix is a non-blocking client (R2DBC instead of JDBC, WebClient instead of RestTemplate), which needs no thread while waiting. My position: use a non-blocking client where one exists, and boundedElastic on a dedicated, explicitly sized scheduler where one does not, which for legacy SOAP clients, some vendor SDKs and file I/O is a permanent situation.

Shared or dedicated schedulers? The shared Schedulers.boundedElastic() is convenient and creates a coupling: every blocking integration in the process competes for the same threads, so one slow dependency starves the rest. A dedicated scheduler per integration is a bulkhead. It costs threads and some configuration. I would use dedicated schedulers for any blocking integration on a request path, and the shared one for incidental things like occasional file reads. The naming matters too: a scheduler named soap-client makes a thread dump immediately interpretable, and the default names do not.

Should you use WebFlux at all if you have blocking dependencies? This deserves a blunt answer: usually no. If most of your I/O is JDBC, WebFlux gives you the complexity of reactive programming and the thread-per-request cost of blocking, which is the worst combination. Spring MVC with virtual threads (Java 21) gives you the scalability with none of the complexity, and it is the correct choice for the large majority of services built on JDBC. WebFlux earns its place when the I/O is genuinely non-blocking end to end, when you are streaming responses, or when you need the composition operators for fan-out.

Sizing boundedElastic. The default 10 * cores is a compromise for a pool whose purpose is holding threads that are not computing. Size it from Little's Law: threads = target_rps * service_time_seconds, plus headroom. The counter-consideration is memory: each platform thread costs about 1 MB of stack, so 500 threads is roughly 500 MB of virtual address space (much less resident in practice, but not nothing). Above a few hundred, the honest answer is that virtual threads or a non-blocking client is the better structure.

Follow-up Q&A

"What is the difference between subscribeOn and publishOn?"

subscribeOn changes where the subscription happens, so it affects the source and everything downstream until something else changes it, and its position in the chain is irrelevant. publishOn changes where subsequent operators run by handing elements to another scheduler's queue, so its position is everything. The consequence worth adding: two subscribeOn calls means the one nearest the source wins and the other is dead code, while two publishOn calls each define their own segment.

"You have a blocking JDBC call in a WebFlux handler. What do you do?"

Wrap it in Mono.fromCallable (not Mono.just, which evaluates eagerly at assembly time) and subscribeOn a dedicated boundedElastic scheduler sized from Little's Law for the target throughput. Then say the real answer: R2DBC if the database supports it, because a non-blocking driver needs no thread while waiting, or Spring MVC with virtual threads if this is a mostly-blocking service, because then WebFlux is buying you nothing.

"Why is a blocking call on Schedulers.parallel() a bug?"

parallel has exactly one thread per core, sized for CPU-bound work where more threads than cores only adds context switching. Blocking one of those threads removes a core's worth of computation capacity from every pipeline in the process, not just yours, because it is a shared static pool. With 4 cores and 4 blocking calls in flight, all CPU-bound reactive work in the JVM stops.

"Your subscribeOn has no effect. Why?"

Almost certainly assembly-time evaluation: something like Flux.fromIterable(loadFromDisk()) where loadFromDisk() is a plain method call evaluated when the pipeline is built, on whatever thread built it, before any subscription exists. subscribeOn cannot move work that has already happened. Wrap it in Flux.defer or Mono.fromCallable. The second possibility is another subscribeOn closer to the source winning; the third is that a publishOn downstream has already moved everything after it, so the segment you were looking at was never governed by subscribeOn anyway.

"How do you keep a trace ID across a thread switch?"

Not with ThreadLocal, which does not survive the switch. Reactor's Context is a subscriber-scoped immutable map that travels with the subscription, written with contextWrite and read with deferContextual. The counterintuitive part is direction: contextWrite affects operators above it, because the subscription flows upward, so reading the code top to bottom the write appears after the read. Since Reactor 3.5 the context-propagation library bridges Context to ThreadLocal automatically for MDC and similar, which is what you want in practice.

"How do you detect blocking calls on the wrong thread?"

BlockHound, a JVM agent that instruments known-blocking JDK methods and throws when one is called from a thread marked non-blocking. Run it in tests and in staging. Without it, the symptom is what the worked example showed: flat throughput and linearly-growing latency, which looks like a downstream problem and is not. A thread dump under load is the manual version, and it will show event-loop threads parked in socket reads.

Common misconceptions

"subscribeOn affects operators after it." It affects the source, no matter where it appears. Operators after it run on that thread too, but only because nothing has changed the thread since, not because of position.

"Multiple subscribeOn calls give you multiple thread switches." The one closest to the source wins; the rest do nothing. Only publishOn composes positionally.

"boundedElastic makes blocking safe." It makes blocking survivable by containing it to a pool sized for it. The thread is still blocked, the pool still has a cap, and exceeding it produces RejectedExecutionException.

"Reactive code cannot have thread-pool problems, that's the point." Reactive code has fewer threads, which makes each one more valuable. A blocking call on an event loop is far more damaging than the same call on a servlet container thread, precisely because there are 8 instead of 200.

"Wrapping in Mono.just is enough to defer it." Mono.just(expensiveCall()) evaluates expensiveCall() immediately, at assembly time. Mono.fromCallable(() -> expensiveCall()) defers it to subscription. This distinction is invisible in the type signature and is the source of a large fraction of "my scheduler is not working" bugs.

Interview delivery note

Say this verbatim: "subscribeOn affects the source and its position does not matter; publishOn affects everything after it and its position is the whole point. And neither one makes a blocking call non-blocking, they just decide which pool gets blocked." That is the complete distinction plus the caveat that matters, in three clauses.

The senior-versus-staff separator is finding the concurrency ceiling with Little's Law. A senior engineer knows blocking on the event loop is bad. A staff engineer looks at flat throughput of 42 rps with a 180 ms service time, computes 42 * 0.18 = 7.6 busy servers, recognises that as the 8 event loops, and has diagnosed it before opening a profiler. Then sizes the replacement pool the same way rather than guessing.

The second signal is anticipating that fixing the bottleneck moves the load downstream. Saying "and I would bound the concurrency to the vendor's documented limit before shipping this, because a 12x throughput increase is a 12x load increase on them" shows you have shipped a fix like this and watched it break something else.

Further reading

  • Project Reactor reference guide, "Threading and Schedulers," including the explicit note on subscribeOn position-independence.
  • Reactor 3.4 release notes on the deprecation of elastic() in favour of boundedElastic(), for the reasoning about unbounded pools.
  • BlockHound documentation, for runtime detection of blocking calls on non-blocking threads.
  • Reactor's Context documentation and the context-propagation library, for carrying request-scoped state across thread boundaries.