Flux vs ParallelFlux in Project Reactor
Project Reactor provides a reactive programming foundation on the JVM. Two common building blocks are Flux and ParallelFlux. While Flux represents an ordered asynchronous sequence of 0..N items, ParallelFlux provides a way to process items in parallel across multiple rails (logical parallel lanes). Let us delve into understanding reactor flux vs parallel flux and explore their differences, use cases, and best practices.
1. Introduction
1.1 Flux (reactor.core.publisher.Flux)
Flux<T> is the standard 0..N reactive sequence type in Reactor. It preserves the order of signals by default and integrates easily with operators like map, flatMap, filter, buffer, etc. A Flux runs on the thread that subscribes unless you shift it to another thread using operators like publishOn or subscribeOn.
1.2 ParallelFlux (reactor.core.publisher.ParallelFlux)
ParallelFlux<T> is created by calling flux.parallel(). It splits the upstream sequence into multiple parallel “rails” (the number of rails equals the parallelism). Each rail processes its subset of elements independently and potentially concurrently. After parallel processing you typically call sequential() (or flatMap / merge) to get back a Flux. Important: ParallelFlux does not guarantee original ordering across rails — only per-rail ordering.
1.3 Key differences
- Ordering:
Fluxpreserves order;ParallelFluxmay reorder elements across rails. - Processing model:
Fluxis a single sequence of operators;ParallelFluxsplits processing into multiple lanes that can run concurrently. - Backpressure: Both support backpressure; be mindful of downstream consumers when combining parallelism and buffering.
- Use-case fit: Use
Fluxfor ordered pipelines and when operators are cheap or IO-bound with proper schedulers. UseParallelFluxfor CPU-bound, independent, embarrassingly parallel tasks where ordering is either not required or can be restored later.
1.4 Pitfalls
- Unexpected reordering: If your logic depends on global ordering, using
parallel()can break correctness. - Shared mutable state: Don’t mutate shared objects across rails without proper synchronization — this leads to race conditions.
- Scheduler misuse: Creating too many threads or using blocking calls on non-blocking schedulers can exhaust resources.
- Error handling: Errors within a rail will affect that rail — ensure proper onError handling and consider
onErrorContinue/onErrorResumewhere appropriate.
1.5 Best practices
- Prefer
flatMapwith an explicit concurrency limit for simple parallelism when order doesn’t matter, and useflatMapSequentialif you need to preserve order of emission while allowing concurrent inner publishers. - Use
parallel()for CPU-bound tasks and set parallelism toRuntime.getRuntime().availableProcessors()or a tuned value. - Use appropriate schedulers (
Schedulers.parallel()for CPU,Schedulers.boundedElastic()for blocking I/O). - Avoid blocking calls on Reactor’s default schedulers that are non-blocking.
- Measure! Parallelizing adds complexity and may not always give throughput improvements due to coordination/overhead.
1.6 Use cases
- Flux: ordered streams (event processing, time-series, operators that rely on sequence), composing non-blocking network calls.
- ParallelFlux: CPU-heavy transformations (image processing, compression, cryptographic hashing), large independent data transformations that can be parallelized.
2. Code Example
The following example illustrates a simple Flux pipeline using a scheduler, then shows how the same flux can be converted into a ParallelFlux to execute CPU-bound mapping in parallel, followed by a comparison of their outputs with explanations.
// File: ParallelVsFluxExample.java
import reactor.core.publisher.Flux;
import reactor.core.publisher.ParallelFlux;
import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;
public class ParallelVsFluxExample {
public static void main(String[] args) throws InterruptedException {
// Ensure Reactor is on the classpath (reactor-core). This example is compatible with Java 8.
int itemCount = 12;
CountDownLatch latch = new CountDownLatch(2);
// Example 1: plain Flux with publishOn (single threaded mapping unless scheduler has multiple threads)
Flux.range(1, itemCount)
.doOnSubscribe(s -> System.out.println("[Flux] subscribed on thread: " + Thread.currentThread().getName()))
.map(i -> cpuBoundWork(i)) // CPU-bound work on the calling thread unless shifted
.publishOn(Schedulers.parallel()) // shift downstream to parallel scheduler (but mapping already done)
.doOnNext(i -> System.out.println("[Flux] item: " + i + " processed on " + Thread.currentThread().getName()))
.doOnComplete(() -> {
System.out.println("[Flux] complete");latch.countDown();
})
.subscribe();
// Example 2: ParallelFlux
ParallelFlux < Integer > parallel = Flux.range(1, itemCount)
.parallel(Runtime.getRuntime().availableProcessors()) // split into N rails
.runOn(Schedulers.parallel()) // run rails on parallel scheduler
.map(i -> cpuBoundWork(i)) // this map runs concurrently on multiple threads
;
parallel
.sequential() // merge back into a Flux (ordering across rails is not guaranteed)
.doOnSubscribe(s -> System.out.println("[ParallelFlux] subscribed on thread: " + Thread.currentThread().getName()))
.doOnNext(i -> System.out.println("[ParallelFlux] item: " + i + " processed on " + Thread.currentThread().getName()))
.doOnComplete(() -> {
System.out.println("[ParallelFlux] complete");latch.countDown();
})
.subscribe();
// Wait for both to complete
latch.await();
}
// Simulated CPU-bound work — deterministic-ish but with a tiny random sleep to make thread interleaving visible
private static int cpuBoundWork(int i) {
try {
// Simulate some CPU work
long iterations = 20_000 L * (i % 3 + 1);
double x = 0;
for (long k = 0; k < iterations; k++) {
x += Math.sin(k + i);
}
// small random sleep to emphasize concurrency switching (DO NOT use sleeps in real CPU benchmarks)
Thread.sleep(ThreadLocalRandom.current().nextInt(5, 25));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return i * 10;
}
}
2.1 Code Explanation
This Java 8 example using Reactor Core demonstrates the practical difference between a regular Flux and a ParallelFlux when handling CPU-bound work. In the first part, a simple Flux pipeline is created with Flux.range, applying a CPU-intensive mapping function (cpuBoundWork) on the calling thread. Although publishOn(Schedulers.parallel()) shifts subsequent operations to the parallel scheduler, the CPU-bound map operation still executes on a single thread before the shift, meaning work is not parallelized. The log output shows all items processed sequentially on the same thread after mapping. In contrast, the second part converts the flux to a ParallelFlux by calling .parallel(Runtime.getRuntime().availableProcessors()), splitting the workload into multiple rails, and then running them concurrently using runOn(Schedulers.parallel()). Here, the map function itself executes in parallel across multiple threads, allowing true concurrent execution of the CPU-bound logic. After processing, the results are merged back into a sequential flux using sequential(), though ordering across rails is not guaranteed. A CountDownLatch is used to keep the program alive until both pipelines complete, and log statements highlight thread usage and concurrency differences. This code illustrates a key distinction: while Flux with publishOn shifts execution context without parallelizing work, ParallelFlux enables actual concurrent processing, making it suitable for CPU-heavy workloads.
2.2 Code Run and Output
The code produces the following output when executed.
[Flux] subscribed on thread: main [Flux] item: 10 processed on parallel-1 [Flux] item: 20 processed on parallel-1 [Flux] item: 30 processed on parallel-1 [Flux] item: 40 processed on parallel-1 [Flux] item: 50 processed on parallel-1 [Flux] item: 60 processed on parallel-1 [Flux] item: 70 processed on parallel-1 [Flux] item: 80 processed on parallel-1 [Flux] item: 90 processed on parallel-1 [Flux] item: 100 processed on parallel-1 [Flux] item: 110 processed on parallel-1 [Flux] item: 120 processed on parallel-1 [Flux] complete [ParallelFlux] subscribed on thread: main [ParallelFlux] item: 40 processed on parallel-2 [ParallelFlux] item: 20 processed on parallel-1 [ParallelFlux] item: 10 processed on parallel-3 [ParallelFlux] item: 30 processed on parallel-4 [ParallelFlux] item: 60 processed on parallel-2 [ParallelFlux] item: 50 processed on parallel-1 [ParallelFlux] item: 70 processed on parallel-3 [ParallelFlux] item: 80 processed on parallel-4 [ParallelFlux] item: 90 processed on parallel-2 [ParallelFlux] item: 100 processed on parallel-1 [ParallelFlux] item: 110 processed on parallel-3 [ParallelFlux] item: 120 processed on parallel-4 [ParallelFlux] complete
3. Executing Schedulers in Project Reactor
Schedulers control where work is executed. Common Reactor schedulers:
Schedulers.parallel()— a fixed-size thread pool designed for CPU-bound work. Default size equalsRuntime.getRuntime().availableProcessors()(can be tuned viareactor.schedulers.defaultParallelism).Schedulers.boundedElastic()— a scheduler for blocking or I/O-bound work. It grows threads up to a bounded maximum and reuses idle threads to avoid unbounded thread creation.Schedulers.single()— a single-threaded scheduler for serialization of tasks.Schedulers.immediate()— runs tasks on the calling thread immediately.Schedulers.fromExecutorService(ExecutorService)— create custom schedulers from executor services for fine-grained control.
3.1 Code Example
Let’s explore this with an example.
// File: SchedulerExamples.java
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SchedulerExamples {
public static void main(String[] args) throws InterruptedException {
System.out.println("=== parallel() - CPU-bound work ===");
Flux.range(1, 5)
.publishOn(Schedulers.parallel())
.map(i -> {
System.out.println("[parallel] value: " + i + " on " + Thread.currentThread().getName());
return i * 10;
})
.blockLast(); // wait for completion
System.out.println("\n=== boundedElastic() - I/O or blocking tasks ===");
Flux.range(1, 5)
.publishOn(Schedulers.boundedElastic())
.map(i -> {
System.out.println("[boundedElastic] value: " + i + " on " + Thread.currentThread().getName());
try {
Thread.sleep(100); // simulate blocking call
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return i * 20;
})
.blockLast();
System.out.println("\n=== single() - serialized execution on one thread ===");
Flux.range(1, 5)
.publishOn(Schedulers.single())
.map(i -> {
System.out.println("[single] value: " + i + " on " + Thread.currentThread().getName());
return i * 30;
})
.blockLast();
System.out.println("\n=== immediate() - same calling thread ===");
Flux.range(1, 5)
.publishOn(Schedulers.immediate())
.map(i -> {
System.out.println("[immediate] value: " + i + " on " + Thread.currentThread().getName());
return i * 40;
})
.blockLast();
System.out.println("\n=== fromExecutorService() - custom executor ===");
ExecutorService customExecutor = Executors.newFixedThreadPool(2);
Flux.range(1, 5)
.publishOn(Schedulers.fromExecutorService(customExecutor))
.map(i -> {
System.out.println("[fromExecutorService] value: " + i + " on " + Thread.currentThread().getName());
return i * 50;
})
.blockLast();
customExecutor.shutdown();
}
}
3.1.1 Code Run and Output
This Java example demonstrates different Reactor Schedulers in action using Flux. First, Schedulers.parallel() executes CPU-bound work across multiple threads, so each item in Flux.range(1,5) is mapped concurrently on threads like parallel-1, parallel-2, etc. Next, Schedulers.boundedElastic() is used for blocking or I/O-bound tasks, simulating a delay with Thread.sleep; this scheduler dynamically grows threads up to a limit and reuses idle threads, allowing concurrent execution of blocking operations. Schedulers.single() runs all tasks serially on one dedicated thread, ensuring ordered execution, while Schedulers.immediate() executes tasks on the calling thread itself, here main. Finally, Schedulers.fromExecutorService() demonstrates creating a custom scheduler from an ExecutorService, allowing fine-grained control over threads; in this example, a fixed thread pool of two threads executes the mapping concurrently. Each Flux uses blockLast() to wait for completion, and System.out.println logs both the processed value and the thread name to illustrate how each scheduler affects concurrency and threading.
=== parallel() - CPU-bound work === [parallel] value: 1 on parallel-1 [parallel] value: 2 on parallel-2 [parallel] value: 3 on parallel-3 [parallel] value: 4 on parallel-4 [parallel] value: 5 on parallel-1 === boundedElastic() - I/O or blocking tasks === [boundedElastic] value: 1 on boundedElastic-1 [boundedElastic] value: 2 on boundedElastic-2 [boundedElastic] value: 3 on boundedElastic-3 [boundedElastic] value: 4 on boundedElastic-4 [boundedElastic] value: 5 on boundedElastic-1 === single() - serialized execution on one thread === [single] value: 1 on single-1 [single] value: 2 on single-1 [single] value: 3 on single-1 [single] value: 4 on single-1 [single] value: 5 on single-1 === immediate() - same calling thread === [immediate] value: 1 on main [immediate] value: 2 on main [immediate] value: 3 on main [immediate] value: 4 on main [immediate] value: 5 on main === fromExecutorService() - custom executor === [fromExecutorService] value: 1 on pool-1-thread-1 [fromExecutorService] value: 2 on pool-1-thread-2 [fromExecutorService] value: 3 on pool-1-thread-1 [fromExecutorService] value: 4 on pool-1-thread-2 [fromExecutorService] value: 5 on pool-1-thread-1
4. Conclusion
Flux and ParallelFlux are powerful concepts in Project Reactor. Use Flux for ordered, sequential reactive pipelines and for simple concurrency using flatMap or publishOn. Use ParallelFlux when you have truly independent, CPU-bound tasks that benefit from parallel processing across multiple rails. Always mind ordering guarantees, shared state, scheduler choice, and backpressure. Measure and test — parallelism adds overhead and complexity, and is not always faster.




