A WebFlux API receives several independent items, applies a heavy transformation to each one, and starts falling behind as the CPU approaches its limit. It is tempting to add subscribeOn(Schedulers.parallel()) and consider the work distributed. The code gained a convincing name; the items, however, still pass through a single worker.
The key is to separate three decisions:
- concurrency: how many operations can be in progress;
- parallelism: how many operations execute at the same time on different cores;
- waiting model: whether the work uses CPU, non-blocking I/O, or blocking I/O.
Virtual threads help sustain many blocking operations. Schedulers.parallel() is a fixed pool of platform threads intended for CPU-bound work. One is not a more modern version of the other.
The following examples use Java 21, Spring Boot 3.5.16, and WebFlux. Afterward, we will compare five scenarios from a preserved local benchmark, including a below-limit control and a load that actually saturated all four available CPUs.
Example Versions and Upgrade Path
This example uses Spring Boot 3.5.16 with Java 21. That combination is supported: the Boot 3.5 line accepts Java 17 through 25, according to the official Spring Boot 3.5 requirements.
The current path is to upgrade the framework to Spring Boot 4.1 while keeping Java 21. Boot 4.1 supports Java 17 through 26, so you do not need to change the JDK merely to adopt that version. Because this is a Boot major-version change, perform the migration and run the application suite before considering a later JDK. The current Spring Boot matrix is the reference for this combination.
Nothing about the semantics of parallel(), runOn(), or subscribeOn() depends on migrating the example to Boot 4.1. Keeping Java 21 also preserves an appropriate LTS baseline for the path.
What subscribeOn Actually Changes
Consider this source:
Flux.range(0, items)
.map(item -> digest(item, iterations))
.subscribeOn(Schedulers.parallel());
subscribeOn chooses the worker where subscription and the upstream chain's request signals happen. It moves the flow to one scheduler worker, but does not distribute the items among the pool's workers. The map remains serial within that subscription.
This can be useful for moving a synchronous source off the thread that started the chain. It is also the right way to isolate a blocking call when applied directly to a Mono.fromCallable, but in that case the correct scheduler is boundedElastic():
Mono.fromCallable(() -> clienteLegado.buscar(id))
.subscribeOn(Schedulers.boundedElastic());
For CPU-bound work with independent items, explicit partitioning is different:
int workers = Runtime.getRuntime().availableProcessors();
Flux.range(0, items)
.parallel(workers)
.runOn(Schedulers.parallel())
.map(item -> digest(item, iterations))
.sequential();
parallel(workers) creates the rails; runOn(...) schedules those rails on workers; sequential() exposes a regular Flux again and merges the results. It does not restore the items' original order.
The number of available processors is a starting point, not a license to manufacture more CPU with a larger integer. If four cores are busy, 32 workers add contention and queues; the processor does not feel pressured to create the other 28 cores.
flatMap, flatMapSequential, and parallel/runOn
These operators solve different problems, although all of them can keep multiple tasks in progress.
flatMap: Concurrency with No Ordering Promise
flatMap eagerly subscribes to inner publishers up to the concurrency limit and emits results as they arrive:
Flux.fromIterable(ids)
.flatMap(
id -> clienteWeb.buscar(id),
32, // concorrência máxima
8 // prefetch
);
This form works well for non-blocking I/O when order does not matter. A concurrency of 32 allows up to 32 operations in progress, but that does not mean 32 calculations are executing in parallel. If the inner publishers remain on the same event loop and spend most of their time waiting for the network, you have concurrency without CPU parallelism.
Do not leave concurrency and prefetch at their defaults out of habit. Set limits based on dependency capacity, the connection pool, and acceptable latency.
flatMapSequential: Concurrent Inside, Ordered Outside
flatMapSequential also subscribes to inner publishers in advance, but emits in source order:
Flux.fromIterable(ids)
.flatMapSequential(id -> clienteWeb.buscar(id), 32, 8);
If item 2 finishes before item 1, item 2's result must wait. This preserves order, but can increase memory retention and latency when an early item is slow. If only one inner publisher may exist at a time, the appropriate operator is concatMap, not concurrency disguised as one.
parallel().runOn(): Rails Executing on Workers
For synchronous, independent CPU-bound transformations, parallel().runOn() distributes items among rails executed by the scheduler:
Flux.fromIterable(comandos)
.parallel(workers)
.runOn(cpuScheduler)
.map(this::calcular)
.sequential();
There is parallelism here when cores are available. The cost is coordination, queues, and possible loss of the original order. For a small number of items or very short work, that cost can exceed the benefit.
The ParallelFlux documentation shows the distinction between creating rails and executing them; the Flux API details the contracts of flatMap and flatMapSequential.
Virtual Threads Neither Replace nor Change parallel()
In Reactor, Schedulers.parallel() remains a fixed pool of platform threads, normally sized according to the number of available processors. Enabling virtual threads in Spring Boot does not convert this scheduler.
With Java 21 or later, boundedElastic() can use virtual threads when the JVM system property reactor.schedulers.defaultBoundedElasticOnVirtualThreads is set before scheduler initialization. It should not go in application.properties; pass it with -D when starting the JVM:
java -Dreactor.schedulers.defaultBoundedElasticOnVirtualThreads=true -jar aplicacao.jar
Another option is to call System.setProperty("reactor.schedulers.defaultBoundedElasticOnVirtualThreads", "true") at the beginning of main, before SpringApplication.run(...) and before any scheduler access.
This matters for blocking tasks isolated on boundedElastic(). It does not speed up a digest, compression, or any other CPU-bound workload. The spring.threads.virtual.enabled=true property, in turn, affects Spring Boot's auto-configured task execution infrastructure; it also does not transform Schedulers.parallel().
The practical rule is simple:
- non-blocking I/O: use the reactive client and control
flatMapconcurrency; - unavoidable blocking I/O: wrap it in
fromCallableand isolate it onboundedElastic(), evaluating virtual threads on Java 21; - independent CPU-bound work: evaluate
parallel(workers).runOn(...), starting withworkersequal to the number of available CPUs.
The Reactor scheduler reference describes the pools and the virtual-thread implementation of boundedElastic().
Executable Project Used in the Test
The preserved project contains only the WebFlux starter and pins the versions used in this example:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>academy.devdojo</groupId>
<artifactId>reactor-cpu-benchmark</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
The endpoint receives the mode, creates eight items by default, and repeatedly executes SHA-256 for each item. There is no sleep and no external I/O:
package academy.devdojo;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
@SpringBootApplication
@RestController
public class BenchmarkApplication {
private final int cores = Runtime.getRuntime().availableProcessors();
private final Scheduler oversized =
Schedulers.newParallel("oversized", cores * 8);
public static void main(String[] args) {
SpringApplication.run(BenchmarkApplication.class, args);
}
@GetMapping("/work")
Mono<Long> work(@RequestParam(defaultValue = "serial") String mode,
@RequestParam(defaultValue = "8") int items,
@RequestParam(defaultValue = "12000") int iterations) {
Flux<Integer> source = Flux.range(0, items);
Flux<Long> result = switch (mode) {
case "subscribe" -> source
.map(i -> digest(i, iterations))
.subscribeOn(Schedulers.parallel());
case "parallel" -> source
.parallel(cores)
.runOn(Schedulers.parallel())
.map(i -> digest(i, iterations))
.sequential();
case "oversized" -> source
.parallel(cores * 8)
.runOn(oversized)
.map(i -> digest(i, iterations))
.sequential();
default -> source.map(i -> digest(i, iterations));
};
return result.reduce(0L, Long::sum);
}
private static long digest(int seed, int iterations) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] value = ByteBuffer.allocate(16)
.putLong(seed)
.putLong(0x5deece66dL)
.array();
for (int i = 0; i < iterations; i++) {
value = md.digest(value);
}
return ByteBuffer.wrap(value).getLong();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
To run it:
mvn -q clean package
java -jar target/reactor-cpu-benchmark-1.0.0.jar
The subscribe mode moves the subscription without splitting the items. The parallel mode uses four rails. The oversized mode creates 32 rails and 32 workers to show the cost of significantly exceeding the available CPU.
The oversized scheduler was kept this way in the preserved project to reproduce the benchmark. In an application, custom schedulers created with Schedulers.newParallel(...) should receive dispose() on shutdown; a Spring bean with destroyMethod = "dispose", as in the instrumentation example below, handles that lifecycle.
What the Local Benchmark Measured
The test ran on August 17, 2026 in this environment:
- Linux 7.0.0-27-generic x86_64;
- OpenJDK 21.0.11;
- Maven 3.9.12;
- Spring Boot 3.5.16;
- 4 logical CPUs;
- server and load client on the same machine, via
localhost; - 8 items per request, with each item calculating SHA-256 12.000 times;
- a 10-second warm-up in parallel mode with concurrency 4;
- a measured duration of 20 seconds per scenario;
- concurrency 1 in the below-limit control and 16 in the high-load scenarios.
In the high-load parallel scenario, a repetition monitored over 15 samples recorded average process CPU usage of 366,27% against a logical capacity of 400%. The load therefore crossed the CPU bottleneck that the test was designed to observe.
| Scenario | Concurrency | Requests | Throughput | Errors | p95 | p99 |
|---|---|---|---|---|---|---|
| serial (event loop), below-limit control | 1 | 2.081 | 104,03 req/s | 0 | 12,63 ms | 13,76 ms |
| serial, high load | 16 | 8.590 | 427,94 req/s | 0 | 72,24 ms | 83,99 ms |
subscribeOn(parallel), high load | 16 | 9.299 | 463,75 req/s | 0 | 89,99 ms | 113,43 ms |
4 rails with parallel/runOn, high load | 16 | 9.246 | 461,61 req/s | 0 | 41,42 ms | 46,64 ms |
| 32 rails/workers, high load | 16 | 9.059 | 452,41 req/s | 0 | 46,16 ms | 51,85 ms |
The repetition used to collect CPU data in four-rail mode achieved 451,62 req/s, zero errors, a p95 of 43,02 ms, and a p99 of 48,63 ms.
The most important result is not a contest over two decimal places of throughput. Under saturation, four rails substantially reduced tail latency compared with the serial and subscribeOn modes, and outperformed the 32-worker scheduler in both throughput and latency. subscribeOn reached 463,75 req/s in the recorded run, but had the worst tail: a p99 of 113,43 ms. It moved the work; it did not parallelize the eight items in each request.
This is a synthetic local CPU test. It does not measure I/O, virtual threads, a real network, containers competing for CPU, or production dependencies. The numbers verify operator semantics and behavior in this environment; they do not estimate another service's capacity.
Diagnosis, Correction, and Production Observability
Every symptom should end with a concrete change and a safe way to confirm its effect. An isolated metric can tell stories too; some just have a more creative script.
| Diagnosis | Concrete correction | What to observe with Micrometer |
|---|---|---|
Serial CPU-bound work merely moved by subscribeOn | For independent items, use parallel(CPUs).runOn(cpuScheduler).map(...).sequential() | Process CPU, tasks.active, tasks.pending, throughput, and p95/p99 for http.server.requests |
| More rails/workers than cores | Start at availableProcessors() and reduce if tail latency worsens without a throughput gain | CPU near the ceiling, growing pending count, tasks completed per second, and p99 |
| Blocking call on the event loop or parallel scheduler | Wrap it in Mono.fromCallable and use boundedElastic(); prefer a non-blocking client when available | pending/active for the isolated scheduler, errors, timeouts, and HTTP p95/p99 |
flatMap opening too much work | Set explicit concurrency and prefetch, aligned with the connection pool and dependency limit | active connections, timeouts, errors, JVM memory, latency, and pending count for the associated scheduler |
flatMapSequential retaining results because of a slow item | Use flatMap if order is not required, or reduce concurrency; use concatMap if serialization is intentional | p99, JVM memory, dependency time, and the difference between started and completed rates |
| CPU saturated, queue growing, and throughput stable | Reduce rails/concurrency or limit admission; scale CPU only after validating the profile | CPU, pending count, request rate, errors, and p95/p99 in the same dashboard and time window |
boundedElastic saturated | Remove blocking where possible; limit concurrency and the isolation queue capacity | tasks pending/active/completed/submitted, rejections, timeouts, and HTTP tail latency |
To instrument a dedicated scheduler, add Actuator and the Reactor integration with Micrometer:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core-micrometer</artifactId>
</dependency>
Then wrap the scheduler that executes the workload:
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.observability.micrometer.Micrometer;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
@Configuration
class SchedulerConfiguration {
@Bean(destroyMethod = "dispose")
Scheduler cpuScheduler(MeterRegistry registry) {
int workers = Runtime.getRuntime().availableProcessors();
Scheduler scheduler = Schedulers.newParallel("cpu-work", workers);
return Micrometer.timedScheduler(
scheduler,
registry,
"reactor.cpu",
Tags.of("workload", "digest"));
}
}
Use this cpuScheduler in runOn. The instrumentation exposes submitted, active, pending, and completed tasks under the chosen prefix. Combine this with Actuator HTTP metrics, process CPU, JVM memory, and dependency metrics. The official Reactor and Micrometer integration provides the instrumentation contract; Actuator documents exporting metrics and supported registries.
Avoid inferring saturation from the global thread count. Besides not representing queues, throughput, or tail latency, that kind of estimate does not cover virtual threads usefully. Production diagnosis must correlate resources, pending work, completion rate, errors, and latency percentiles.
When to Adopt It and When to Back Off
The recommendation is firm but conditional: use parallel().runOn() when the work is demonstrably CPU-bound, the items are independent, there is enough volume to pay for coordination, and the metrics show improved tail latency or throughput. Start with no more than one worker per CPU available to this workload.
Do not use the technique to accelerate I/O, to “enable” virtual threads, or to compensate for a blocking dependency. In those cases, control concurrency with flatMap, preserve non-blocking I/O, or isolate legacy code on boundedElastic().
If the metrics show saturation without a capacity gain, back off or reduce concurrency instead of adding workers.
Next Step
Reproduce all four modes with a CPU-bound transformation from your service, instrument a dedicated scheduler with Micrometer, and compare throughput, errors, p95, and p99 starting at one worker per CPU. Only then adjust the rails—one number at a time.