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:
subscribeOnposition does not matter. Anywhere in the chain, it affects the source. TwosubscribeOncalls: the one closest to the source wins, and the other does nothing.publishOnposition matters entirely. It affects operators after it, so multiplepublishOncalls 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
| Scheduler | Threads | Use for |
|---|---|---|
Schedulers.parallel() | Fixed, = CPU cores | CPU-bound work: transforms, parsing, computation |
Schedulers.boundedElastic() | Elastic, capped at 10 x cores by default, queue bounded | Blocking calls: JDBC, blocking HTTP clients, file I/O |
Schedulers.single() | One | Serialising work that must not run concurrently |
Schedulers.immediate() | Caller's | Explicitly "do not switch" |
Schedulers.fromExecutor(e) | Yours | Integrating 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
subscribeOnposition-independence. - Reactor 3.4 release notes on the deprecation of
elastic()in favour ofboundedElastic(), for the reasoning about unbounded pools. - BlockHound documentation, for runtime detection of blocking calls on non-blocking threads.
- Reactor's
Contextdocumentation and thecontext-propagationlibrary, for carrying request-scoped state across thread boundaries.