Introduction
Does wrapping a blocking Pulsar consumer in Flux.generate give you backpressure? Does adding backpressure cost you throughput?
The first answer is no — wrapping blocking calls in Reactor operators creates something that looks reactive but behaves like a fire hose. The second answer is also no: a properly implemented reactive pipeline is dramatically faster than both alternatives. Our JMH benchmark shows the reactive approach hitting 6.29 million messages/sec — 27x faster than the Flux-wrapped approach (232k msg/sec) and 24,800x faster than plain blocking (253 msg/sec). The reactive pipeline is faster because it does less work per message: no thread blocking, no Mono.fromCallable allocation, no boundedElastic scheduling overhead.
In Reactive Telegram Client we built a reactive pipeline that feeds messages into Pulsar. In Pulsar vs Kafka Part 2 we benchmarked broker throughput under load. This post focuses on what happens between those two endpoints: a source-to-sink pipeline that reads from one Pulsar topic and writes to another, benchmarked rigorously with JMH.
All benchmarks, source code, and JMH result files are available in the reactive-source-sink repository.
The Problem — Source to Sink
A source-to-sink pipeline is the fundamental building block of stream processing. Every Flink job, every Kafka Streams topology, every reactive microservice does this: consume from one endpoint, optionally transform, produce to another.
But “source” and “sink” are not limited to message brokers. The same pattern appears everywhere data flows between components with different throughput capacities:
- Broker to Broker — Kafka topic to Pulsar topic, or between clusters in different regions
- Broker to Database — consuming events and writing to PostgreSQL, Cassandra, or Elasticsearch
- Service to Service — an HTTP or gRPC microservice that reads from an upstream and writes to a downstream, where the downstream may be slower, rate-limited, or degraded
- Database to Broker — CDC (Change Data Capture) pipelines reading a binlog and publishing to Kafka/Pulsar
- API Gateway to Backend — an edge service accepting client requests at a rate the backend cannot sustain
In every case, the critical property is that the source and sink have different throughput capacities. If the source reads faster than the sink can write, messages accumulate somewhere. Where they accumulate — and how that accumulation is controlled — is the entire difference between a system that works and one that crashes at 3 AM.
In a microservice architecture, this problem compounds. Service A calls Service B, which calls Service C. If Service C slows down and Service B has no backpressure, B’s memory grows until it crashes — then A has no downstream, and the failure cascades back up the chain. Reactive backpressure turns this into a controlled slowdown: C signals B to slow down, B signals A, and the entire pipeline throttles to the speed of the slowest component.
We benchmark this pattern using Pulsar as both source and sink, but the Publisher and Subscriber implementations we build are broker-agnostic. The same demand-driven source can wrap a JDBC connection, an HTTP client, or any I/O channel.
Project Structure
The benchmark project is split into four Gradle modules — one shared core and one per approach. Each pipeline module declares its own JMH benchmark and produces a separate results.json file.
reactive-source-sink/
├── core/ # Shared interfaces, BenchmarkContext, models
├── blocking/ # Plain blocking pipeline + JMH benchmark
├── flux-wrapped/ # Flux-wrapped blocking pipeline + JMH benchmark
└── reactive-backpressure/ # Custom Publisher/Subscriber + JMH benchmark
The core module provides a BenchmarkContext that creates a Pulsar client, a source consumer, a sink producer (with batching enabled — 1000 messages per batch, 1ms publish delay), and a daemon background feeder that continuously publishes 1 KB messages into the source topic so consumer.receive() never starves during a benchmark run.
public BenchmarkContext(int messageSize) throws PulsarClientException {
this.consumer = config.getPulsarClient()
.newConsumer(Schema.BYTES)
.topic(sourceTopic)
.subscriptionName(subscriptionName)
.subscriptionType(SubscriptionType.Shared)
.receiverQueueSize(10_000)
.subscribe();
this.producer = config.getPulsarClient()
.newProducer(Schema.BYTES)
.topic(sinkTopic)
.enableBatching(true)
.batchingMaxMessages(1000)
.batchingMaxPublishDelay(1, TimeUnit.MILLISECONDS)
.blockIfQueueFull(true)
.maxPendingMessages(50_000)
.create();
// Background feeder keeps source topic continuously populated
this.feederThread = new Thread(() -> runFeederLoop(payload));
this.feederThread.setDaemon(true);
this.feederThread.start();
}
This setup is key to fair JMH benchmarking — the consumer never blocks waiting for messages, so we measure the pipeline cost, not the broker latency.
Three Approaches
Approach 1 — True Reactive (Backpressure)
The reactive approach implements the Reactive Streams specification directly with a custom Publisher<byte[]>. The JMH benchmark builds a fresh pipeline per invocation:
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 1, time = 30, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 3, time = 30, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@Threads(1) // parallelism is inside the flux
@State(Scope.Benchmark)
public class ReactiveJmhBenchmark {
private static final int MESSAGE_SIZE = 1024;
private static final int SOURCE_SCHEDULER_THREADS = 2;
private static final int PIPELINE_SCHEDULER_THREADS = 8;
private static final int BATCH_SIZE = 1024;
private static final int FLATMAP_CONCURRENCY = 256;
@Benchmark
@OperationsPerInvocation(BATCH_SIZE)
public void runFlux(Blackhole bh) {
PulsarReactiveSource source = new PulsarReactiveSource(ctx.consumer, sourceScheduler);
Flux<MessageId> sendFlux = Flux.from(source)
.publishOn(pipelineScheduler)
.map(BenchmarkMessage::deserialize)
.flatMap(msg -> Mono.fromCompletionStage(
() -> ctx.producer.newMessage()
.key(msg.getKey())
.value(msg.serialize())
.sendAsync()),
FLATMAP_CONCURRENCY)
.doOnNext(id -> processed.incrementAndGet());
StepVerifier.create(sendFlux)
.expectNextCount(BATCH_SIZE)
.thenCancel()
.verify(VERIFY_TIMEOUT);
bh.consume(sendFlux);
}
}
The @OperationsPerInvocation(BATCH_SIZE) annotation tells JMH that each runFlux() invocation processes 1024 messages, so the reported ops/s reflects per-message throughput. StepVerifier.expectNextCount(BATCH_SIZE).thenCancel() is the JMH-friendly way to bound work — it subscribes, waits for exactly 1024 onNext signals, then cancels the subscription.
The pipeline does four things per message:
- Source emits raw bytes — the custom
PulsarReactiveSourcereadsbyte[]directly from the Pulsar consumer with no deserialization on the receive thread (the receive loop stays cheap and CPU-bound) publishOn(pipelineScheduler)— switches threads from the source receive thread to a dedicated pipeline scheduler (8 threads). The deserialization and downstream operators all run on the pipeline schedulermap(deserialize)— converts raw bytes intoBenchmarkMessageon the pipeline schedulerflatMap(sendAsync, 256)— fires up to 256 concurrent async sends.Mono.fromCompletionStagewraps Pulsar’ssendAsync()future without blocking any thread
The Source — Demand-Driven Receive Loop with Batch Drain
The PulsarReactiveSource is a Publisher<byte[]> (not Publisher<BenchmarkMessage> — deserialization is intentionally pushed downstream to avoid CPU work on the receive thread):
public class PulsarReactiveSource implements Publisher<byte[]> {
private static final int RECEIVE_TIMEOUT_MS = 100;
private static final int MAX_BATCH_DRAIN = 32;
private final Consumer<byte[]> consumer;
private final Scheduler scheduler;
public PulsarReactiveSource(Consumer<byte[]> consumer, Scheduler scheduler) {
this.consumer = consumer;
this.scheduler = scheduler;
}
private void receiveLoop() {
while (!cancelled.get()) {
if (demand.get() == 0) {
LockSupport.park(this); // sleep until demand arrives
continue;
}
// Block briefly for the first message of this drain cycle
Message<byte[]> first = consumer.receive(RECEIVE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
if (first == null) continue;
emit(first);
// Opportunistic batch drain — pull more from prefetch buffer
int batched = 1;
while (batched < MAX_BATCH_DRAIN && demand.get() > 0) {
Message<byte[]> next = consumer.receive(0, TimeUnit.MILLISECONDS);
if (next == null) break;
emit(next);
batched++;
}
}
}
}
Three optimizations make this efficient:
- Park-on-zero-demand. When demand reaches zero,
LockSupport.park()puts the thread to sleep with nanosecond overhead. No polling, no busy-wait. - Opportunistic batch drain. After the blocking
receive()returns the first message, the loop polls non-blocking (receive(0, MILLISECONDS)) for up to 31 more messages from Pulsar’s local prefetch buffer. One blocking call serves a batch of up to 32 messages. - No deserialization on the source thread. The source emits raw
byte[]and lets the downstreampublishOnswitch threads before deserializing. This keeps the receive loop tight.
Approach 2 — Flux-Wrapped Blocking (No Real Backpressure)
This approach uses the plain blocking Pulsar client wrapped in Reactor operators. It looks reactive. It is not.
@BenchmarkMode(Mode.Throughput)
@Threads(1)
@State(Scope.Benchmark)
public class FluxWrappedJmhBenchmark {
private static final int MESSAGE_SIZE = 1024;
private static final int PARALLELISM = 8;
private static final int BATCH_SIZE = 1024;
@Benchmark
@OperationsPerInvocation(BATCH_SIZE)
public void runFlux(Blackhole bh) {
Long processed = Flux.<byte[]>generate(sink -> {
try {
Message<byte[]> msg = ctx.consumer.receive(5, TimeUnit.SECONDS);
if (msg != null) {
sink.next(msg.getData());
ctx.consumer.acknowledgeAsync(msg);
} else {
sink.complete();
}
} catch (PulsarClientException e) {
sink.error(e);
}
})
.take(BATCH_SIZE)
.parallel(PARALLELISM)
.runOn(scheduler)
.flatMap(bytes -> Mono.fromCallable(() -> {
ctx.producer.send(bytes);
return bytes;
}))
.sequential()
.count()
.block();
bh.consume(processed);
}
}
Flux.generate is pull-based and synchronous — it calls the generator function once per downstream request. This looks like backpressure: downstream pulls, upstream responds.
But consumer.receive() does not know about downstream demand. It blocks the calling thread until a message arrives or the timeout expires. The Pulsar consumer’s prefetch buffer fills independently of anything Reactor does. The parallel(8).runOn(scheduler) rails distribute the messages across 8 threads, but each thread blocks on producer.send() for the entire send duration.
The trap is subtle. The parallel(8) operator looks like it adds parallelism, but each rail still blocks on a synchronous producer.send(). With 8 rails and 8 threads, you get exactly 8 in-flight sends — no more. Compare this to the reactive approach, which maintains 256 concurrent in-flight sendAsync() operations on a single source thread.
Approach 3 — Plain Blocking (JMH Threads)
No pretense of reactive. JMH manages 8 worker threads via @Threads(8), and each invocation does exactly one receive-send pair.
@BenchmarkMode(Mode.Throughput)
@Threads(8)
@State(Scope.Benchmark)
public class BlockingJmhBenchmark {
private static final int MESSAGE_SIZE = 1024;
private static final int RECEIVE_TIMEOUT_SECONDS = 5;
@Benchmark
public void readWrite(Blackhole bh) throws Exception {
Message<byte[]> msg = ctx.consumer.receive(RECEIVE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (msg == null) return;
ctx.producer.send(msg.getData());
ctx.consumer.acknowledge(msg);
bh.consume(msg);
}
}
This is the baseline. Each readWrite() invocation processes exactly one message — there is no @OperationsPerInvocation annotation because each call is one operation. JMH spawns 8 threads, each running readWrite() in a tight loop. The pipeline is sequential per worker — throughput is bounded by the round-trip latency of receive() + send().
Benchmark Setup
All three benchmarks run as JMH microbenchmarks against the same Pulsar instance with identical JVM settings.
Host Hardware
| Parameter | Value |
|---|---|
| CPU | AMD Ryzen 9 7945HX (16 cores / 32 threads) |
| RAM | 44 GB DDR5 |
| Storage | NVMe SSD |
| OS | Ubuntu Linux (kernel 6.x) |
| Java | OpenJDK 25.0.2 |
JMH Configuration
| Parameter | Value |
|---|---|
| Mode | Throughput (Mode.Throughput) |
| Warmup | 1 iteration × 30 seconds |
| Measurement | 3 iterations × 30 seconds |
| Forks | 1 |
| JVM | -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=20 |
| Force GC | yes (between iterations) |
| Pulsar batching | 1000 msgs/batch, 1ms max delay |
| Receiver queue | 10,000 |
Test Parameters
| Parameter | Value |
|---|---|
| Message size | 1 KB |
Reactive BATCH_SIZE |
1024 (per benchmark invocation) |
Reactive FLATMAP_CONCURRENCY |
256 |
| Reactive pipeline scheduler | 8 threads |
| Reactive source scheduler | 2 threads |
Flux-wrapped PARALLELISM |
8 rails |
Flux-wrapped BATCH_SIZE |
1024 |
Blocking @Threads |
8 |
Throughput Results
JMH Reported Scores
JMH reports its primary metric as ops/s. For the blocking benchmark, each invocation is one message, so ops/s directly equals msg/sec. For the reactive and Flux-wrapped benchmarks, each invocation processes a batch of 1024 messages — to translate the JMH score into per-message throughput, we multiply by BATCH_SIZE.
| Benchmark | JMH Score (ops/s) | Batch Size | Throughput (msg/sec) |
|---|---|---|---|
| Reactive | 6,140.52 | × 1,024 | 6,287,892 |
| Flux-Wrapped | 226.67 | × 1,024 | 232,114 |
| Blocking | 253.67 | × 1 | 253 |
Throughput Comparison
| Metric | Reactive | Flux-Wrapped | Blocking |
|---|---|---|---|
| Throughput (msg/sec) | 6,287,892 | 232,114 (-96.3%) | 253 (-100%) |
| Throughput (MB/sec) | ~6,140 | ~226 (-96.3%) | ~0.25 (-100%) |
| vs Reactive | baseline | 27.1x slower | 24,855x slower |
| vs Blocking | 24,855x faster | 917x faster | baseline |
| JMH score variance (raw) | 6073-6180 (1.7%) | 224-229 (2.0%) | 248-260 (4.7%) |
Percentages compare each approach to Reactive.
Three patterns dominate:
- Reactive is 27x faster than the wrapper. The custom
Publisher/Subscriberpattern with non-blockingsendAsync()and a 256-slot concurrency window dramatically outperforms blockingproducer.send()on 8 parallel rails. - Flux-wrapped is 917x faster than blocking. Even fake reactive patterns beat sequential blocking — the
parallel(8)operator at least gets 8 concurrent sends going, while pure blocking serializes everything per worker. - Reactive is the most stable. JMH’s iteration variance is just 1.7% for reactive vs 4.7% for blocking — the demand-driven design produces predictable per-iteration throughput.
Why the Difference Is So Large
The 27x gap between reactive and Flux-wrapped, and the 24,000x gap between reactive and blocking, deserve explanation.
Reactive: Sliding Window of 256 Async Sends
The reactive pipeline maintains 256 concurrent in-flight sendAsync() operations regardless of how many test threads are running. Each send is non-blocking — it returns a CompletableFuture immediately, and the source thread is free to receive more messages while previous sends are still in flight. Pulsar’s producer batches these aggressive sends at the broker level (1000 messages per batch, 1ms publish delay), so each TCP roundtrip carries up to 1 MB of payload.
The combination of:
- Source-side batch drain (32 messages per blocking
receive()) - Pipeline scheduler (8 threads handling deserialization and operator chaining)
- Sink-side concurrency (256 in-flight async sends)
- Pulsar producer batching (1000 messages per network call)
…produces a pipeline where reads, deserialization, and writes all overlap continuously. There is essentially no idle time on any thread.
Flux-Wrapped: 8 Threads Blocked on Synchronous Sends
The parallel(8).runOn(scheduler).flatMap(send) chain looks parallel, but each of the 8 rails calls a blocking producer.send(). Even with Pulsar producer-side batching, each rail can only complete one send-batch round-trip at a time. The throughput ceiling is roughly 8 × (1 / send-latency) — for typical localhost Pulsar, that’s a few hundred KB per second per rail.
The Flux.generate source compounds the problem: each call to consumer.receive() blocks the generator thread, so the source-side parallelism is limited too. The pipeline is bottlenecked at both ends.
Blocking: One Message at a Time per Worker
The blocking benchmark has 8 JMH threads, each running:
msg = consumer.receive();
producer.send(msg.getData());
consumer.acknowledge(msg);
There is no overlap between receive and send within a worker. Each message round-trips through the broker before the next one starts. With 8 workers and a single-partition topic, throughput is gated by Pulsar’s per-subscriber dispatch rate — and for blocking sync sends, that comes out to about 30 msg/sec per worker.
When Backpressure Actually Saves You
The benchmark measures the happy path with a fast sink. Failure modes that show up under sink degradation — the entire reason backpressure exists — do not appear when the sink is as fast as the source. Here is what would happen in a longer-running production pipeline.
Memory Bloat and OOM
A consumer reading 1 KB messages at 6.29M msg/sec accumulates ~6 GB/sec in JVM memory if the sink stalls. The benchmark’s -Xmx4g heap fills in well under a second of complete sink stall. With backpressure, the source parks when the sink stops requesting and messages stay in Pulsar — which is designed to buffer to disk via BookKeeper. With the wrapper, every byte sits in JVM memory until the sink recovers or the JVM dies.
Cascading Failures in Microservices
In a multi-service pipeline — Source A -> Transform -> Sink B — if Sink B degrades and Transform does not propagate backpressure, Transform’s memory grows until it crashes. Then Source A has no consumer, and Pulsar’s retry policy kicks in. When Transform restarts, it replays the backlog plus new messages. The cycle repeats.
With true reactive backpressure, the Transform service signals Source A to slow down. Source A signals its upstream. The entire pipeline throttles to the speed of the slowest component. No memory growth, no restarts, no replay storms.
When You Do Not Need Reactive
Not every pipeline needs a custom Publisher and Subscriber. Here is an honest assessment:
- < 1,000 msg/sec on matched hardware: plain blocking with a JMH-style thread pool reaches 253 msg/sec at 8 threads. For CRUD-style microservices that fit this envelope, the operational simplicity is worth the throughput ceiling.
- Short-lived batch jobs: if the pipeline runs for seconds, memory growth is irrelevant. Use whatever is simplest.
- Prototype or proof-of-concept:
Flux.generatewrappers are quick to write and demonstrate the concept. Just do not ship them to production expecting backpressure or competitive throughput.
Reactive backpressure pays off when:
- Source and sink have different performance characteristics (remote cluster, database, HTTP API)
- The sink has variable latency (network jitter, GC pauses, load spikes)
- The pipeline runs continuously for hours or days
- You cannot afford restarts (financial systems, real-time analytics, SLA-bound services)
- Throughput matters — a 27x speedup over the next-best alternative is hard to ignore
Choosing Your Approach
| Factor | Plain Blocking | Flux-Wrapped | True Reactive |
|---|---|---|---|
| Throughput | 253 msg/sec | 232,114 msg/sec | 6,287,892 msg/sec |
| Sends in flight | 1 per worker × 8 | 8 (parallel rails) | 256 |
| Blocking calls | 2 per message | 2 per message (in pool) | 0 |
| Backpressure | Sequential (natural) | None (illusion) | Full (demand-driven) |
| JMH variance | 4.7% | 2.0% | 1.7% |
| Complexity | Trivial | Moderate (deceptive) | Higher (custom Publisher/Subscriber) |
| Best for | Low volume, simplicity | Prototypes, POCs | Production, high throughput, SLA-bound |
Conclusion
The JMH numbers tell an unambiguous story: a properly implemented reactive pipeline is 27x faster than wrapped blocking and 24,855x faster than plain blocking. The reactive approach hits 6.29 million messages per second on a single-machine Pulsar setup with 1 KB messages — three orders of magnitude beyond what the wrapped approach can achieve.
The key insight is architectural. The reactive pipeline maintains 256 concurrent in-flight async sends, lets reads and writes overlap continuously, batches at both the source (32-message drain) and the sink (Pulsar’s 1000-message producer batching), and never blocks a thread on producer.send(). The Flux-wrapped approach, despite using parallel(8) rails, achieves only 8 concurrent sends because each rail is bottlenecked on a blocking send() call. Plain blocking serializes everything per worker.
Wrapping blocking code in Flux.generate and Mono.fromCallable does not confer backpressure. It confers the syntax of reactive code without the semantics. The Pulsar broker keeps pushing, the prefetch buffer keeps filling, and each producer.send() blocks a thread regardless of what your flatMap concurrency limit says. The benchmark cannot show the catastrophic failure mode because the sink is as fast as the source — but in production with a degraded sink, the wrapper’s memory grows unbounded while the reactive pipeline self-regulates.
The reactive approach is not just safer. It is dramatically faster. There is no tradeoff to make.
References
- Reactive Streams Specification
- Project Reactor — CoreSubscriber
- Project Reactor — Flux.generate
- Apache Pulsar — Producer.sendAsync()
- JMH — @OperationsPerInvocation
- JMH — Best Practices for Microbenchmarks
- LockSupport.park / unpark
- Reactive Telegram Client: Polling, Pipelines, and Pulsar
- Apache Pulsar vs Kafka: Benchmarking — Part 2
- Benchmark Source Code