A reactive stream is an asynchronous data flow where the subscriber controls the pace of consumption. Unlike traditional push models (where a source blasts data and the consumer drowns), reactive streams implement pull-based backpressure: the subscriber says 'I want 10 items', the publisher sends exactly 10, and the subscriber signals when it wants more. This contract, formalised in the Reactive Streams specification and implemented by Project Reactor, RxJava, and java.util.concurrent.Flow, prevents memory exhaustion and keeps asynchronous pipelines balanced. This article unpacks how demand propagates upstream, what happens when sources violate the contract, and the patterns to handle overflow without losing data or stalling.
The Publisher contract
The core commitment is simple but strict: Publisher never sends more items than Subscriber requested. Subscription begins with an empty demand counter. Subscriber calls subscription.request(n) to increment demand by n. Publisher may send up to n items. Each onNext() decrement demand by 1. Demand is additive: requesting 5, then requesting 10 more, gives demand of 15. Demand never goes negative; double requests are safe. Once demand is exhausted, Publisher stops sending immediately; Subscriber must request more to resume flow.
subscription.request(10); // subscriber: I want 10
// publisher sends at most 10 onNext() calls
subscription.request(5); // subscriber: I want 5 more
// publisher may now send up to 5 more (15 total - 10 already sent)This is the heart of backpressure: the subscriber controls the data rate, not the publisher. A slow consumer that requests 1 item at a time forces the publisher to produce slowly. A fast consumer that requests 1000 items allows the publisher to burst. No loss of data, no blocking, no memory buildup.
request(N) propagates upstream
Backpressure is not enforced magically at the subscription boundary. Instead, each operator in the chain is a Publisher-Subscriber pair that propagates demand upstream.
When you chain Flux.range(1, 100).map(x -> x * 2).subscribe(subscriber), the subscribe call creates a chain:
Subscriber → map-Subscriber (inner of map) → range-Subscriber (inner of range) → range-Publisher → demand signals → map-Publisher.
When your subscriber calls subscription.request(10), it is actually requesting from the map operator. The map operator receives that request, calls request(10) on the range operator, which sends 10 items. Map transforms them, passes them to your subscriber. Critically: if your subscriber requests 10, the map operator requests 10 from range, and range sends exactly 10. Demand propagates all the way upstream.
Flux.range(1, 100)
.map(x -> x * 2)
.delayElement(Duration.ofMillis(10)) // slow consumer
.subscribe(
value -> System.out.println(value),
error -> error.printStackTrace(),
() -> System.out.println("done")
); // internally: subscription.request(unbounded or 1 by default)The delayElement operator receives a subscription request from downstream, but holds backpressure: it only requests new items as it finishes emitting the previous ones. This keeps the upstream from flooding memory.
The Reactive Streams TCK rules
The Reactive Streams specification is formalised in a Technology Compatibility Kit (TCK) with 32 rules covering all valid and invalid transitions. The critical ones:
Additive demand: request(n1) followed by request(n2) means demand is now n1 + n2. A subscriber can call request multiple times to accumulate demand.
Bounded emission: Publisher must never send more than the cumulative requested items. Violating this is a spec breach; the subscriber is free to cancel or throw.
Serialized onNext: onNext(), onError(), and onComplete() must never be called concurrently. Publisher uses a queue or lock to serialise signals, even in multithreaded sources.
Idempotent cancel: Calling subscription.cancel() multiple times is safe and has no effect after the first call.
Request zero is invalid: request(0) is illegal; it raises an error. Negative requests are also invalid.
The TCK includes a test suite that verifies a Publisher implementation against these rules. Non-conformant publishers will fail TCK tests and should not be used in production reactive code.
request(Long.MAX_VALUE): opting out
Some subscribers are unbounded consumers: they want all available data as fast as the publisher can produce it. Calling request(Long.MAX_VALUE) signals 'send everything now, do not wait for incremental requests.' This is a common pattern for synchronous or blocking subscribers that have their own queueing downstream.
List<Integer> collected = new ArrayList<>();
Flux.range(1, 100)
.subscribe(
value -> collected.add(value),
error -> error.printStackTrace(),
() -> System.out.println("got " + collected.size() + " items")
); // default subscription.request(Long.MAX_VALUE)Most reactive libraries (Reactor, RxJava) default to Long.MAX_VALUE if you use the simple callback subscribe overload. This disables per-item backpressure, pushing responsibility for buffering back onto the publisher or the operator chain.
If you really do want bounded, pull-based backpressure, call subscribe with a custom Subscriber that controls request size:
Flux.range(1, 100).subscribe(new Subscriber<Integer>() {
private Subscription sub;
@Override
public void onSubscribe(Subscription s) {
sub = s;
sub.request(10); // start with 10
}
@Override
public void onNext(Integer v) {
System.out.println(v);
if (v % 10 == 0) sub.request(10); // request more after every 10 items
}
public void onError(Throwable e) {}
public void onComplete() {}
});Prefetch and replenish patterns
Demanding 1 item at a time causes excessive context switching and reduces throughput: a request round-trip is expensive. Demanding all items at once (Long.MAX_VALUE) disables backpressure. The pragmatic middle ground is prefetch: demand a batch size (e.g., 32, 128), and when items are consumed, request more batches.
Project Reactor's prefetch() parameter on operators like flatMap, concatMap sets how many items to buffer between stages. A prefetch(128) tells the source 'send me up to 128 items, then wait for the next signal.' This keeps the pipeline full without drowning the subscriber.
Flux.range(1, 10000)
.flatMap(
x -> expensiveAsyncOp(x),
32, // concurrency: at most 32 in flight
256 // prefetch: buffer 256 items from source
)
.subscribe(System.out::println);The replenish pattern is common in custom subscribers: accumulate items in a buffer, process them, then request more. This defers backpressure to the source while keeping local buffers bounded. Reactor operators use this internally for publishers that emit faster than they can be consumed downstream.
Overflow strategies: buffer, drop, latest
When a publisher produces faster than a subscriber consumes, the source must decide: buffer items (risking memory), drop items (risking loss), or drop old items to make room (risking staleness). Reactor defines these strategies on FluxSink when you use Flux.create() to wrap non-reactive sources.
BUFFER (default): Queue items up to a limit (usually 256). If the queue fills, the emitter blocks or returns an error. This preserves data but can deadlock if the subscriber is blocked waiting for the emission thread.
DROP: Discard new items if the subscriber is not ready. No memory buildup, but data loss. Use only when items are frequent and staleness is acceptable (e.g., sensor readings, mouse events).
LATEST: Keep only the most recent item. Drop everything in between. Similar to DROP for loss, but if the subscriber eventually catches up, it will see the latest state, not nothing. Useful for status updates where only the newest state matters.
ERROR: Throw an error if backpressure limit is exceeded. Fail fast rather than silently drop or block.
Flux.<String>create(emitter -> {
for (String event : highRateEventSource) {
emitter.next(event);
}
}, FluxSink.OverflowStrategy.LATEST); // keep only the newest eventThe boundary at Flux.create with OverflowStrategy
Reactive Streams backpressure only works when all operators honour the contract. If you wrap a non-reactive source (a callback-based API, a blocking iterator, or an imperative loop), you must use Flux.create() and choose an OverflowStrategy to handle the impedance mismatch.
Inside the Flux.create() callback, the emitter (the source thread) is not aware of downstream demand. It just calls emitter.next(item) repeatedly. The strategy decides what happens if downstream is slow:
- BUFFER: Reactor enqueues items internally; if the queue gets full, the next
next()call blocks or returns an error code. Prevents data loss but can cause the source thread to pause unpredictably. - DROP: Items are silently dropped if the queue is full. Fast source, no backpressure signalling to the source—it just keeps emitting and does not know anything was dropped.
- LATEST: Only the most recent item is kept. Older items in the queue are discarded to make room. Good for sampled data where only the newest reading is useful.
Choose your strategy based on the source's characteristics and your tolerance for data loss. A database cursor (bounded, controlled) can use BUFFER. A high-frequency sensor (unbounded, best-effort) should use LATEST or DROP.
publishOn vs subscribeOn: where the queue lives
Operators like publishOn and subscribeOn control which thread executes publisher and subscriber code, and they introduce internal queues that interact with backpressure.
subscribeOn(scheduler): Moves the subscription and upstream production to a scheduler thread. The source produces on that thread, and upstream operators run there. The queue lives between the source and the first downstream operator. Backpressure still propagates: if downstream is slow, the source thread stalls.
publishOn(scheduler): Moves the delivery to a scheduler thread. The source produces on its original thread, items are queued, and the scheduler thread consumes the queue and delivers to downstream subscribers. This decouples production from consumption and can buffer items. If downstream is slow, the queue grows; if it hits the backpressure limit (usually 256 items), the source thread stalls or the queue returns an error.
Flux.range(1, 1000000)
.subscribeOn(Schedulers.boundedElastic()) // produce on a pool thread
.publishOn(Schedulers.single()) // deliver on a different single thread
.subscribe(v -> processSlowly(v)); // downstream subscriberBoth operators introduce queues that participate in the backpressure system. Understanding where the queue lives is critical for tuning prefetch sizes and preventing unexpected buffering or data loss.
java.util.concurrent.Flow: the standard interface
java.util.concurrent.Flow (Java 9+) is the standard reactive streams interface baked into the JDK. It defines Publisher<T>, Subscriber<T>, Subscription, and Processor<T,R> exactly as per the Reactive Streams spec. All JDK APIs that provide streams (e.g., SubmissionPublisher) use this interface.
public interface Subscriber<T> {
public void onSubscribe(Subscription subscription);
public void onNext(T item);
public void onError(Throwable throwable);
public void onComplete();
}
public interface Subscription {
public void request(long n);
public void cancel();
}Reactor, RxJava, and other libraries adapt between their own internal types and java.util.concurrent.Flow. If you are building a library or a low-level reactive component, implement Flow.Publisher and the JDK types will interoperate with Reactor and friends via adapters. For high-level application code, use Reactor's Flux and Mono; they hide the low-level interface.
RxJava Flowable vs Observable
RxJava 1 had Observable, which did not support backpressure (it pushed data as fast as it could). RxJava 2 introduced Flowable<T> with full backpressure support via java.util.concurrent.Flow, and kept Observable for sources that do not support pull-based demand.
Flowable: Respects backpressure. Demand propagates; a slow subscriber slows the source. Use this for any source where data loss is unacceptable (database results, file I/O, network responses).
Observable: Does not support backpressure. It emits at will; if the subscriber is slow, items are buffered or dropped. Use this only for inherently bounded sources (UI events, click streams, timer ticks) where backpressure is not applicable.
// Flowable: supports backpressure
Flowable.range(1, 1000000)
.observeOn(Schedulers.io())
.subscribe { v -> println(v) }
// Observable: does not; use onBackpressureBuffer if you need buffering
Observable.interval(1, TimeUnit.MILLISECONDS)
.onBackpressureBuffer()
.subscribe { v -> println(v) }The rule: if you have a source that can be pulled (unbounded data), wrap it in Flowable. If it's inherently push or bounded, Observable is simpler and slightly faster.
Testing with StepVerifier.thenRequest
Project Reactor provides StepVerifier for testing reactive streams with fine-grained control over demand signals. The thenRequest(n) step emits a request(n) signal and verifies the publisher's response.
StepVerifier.create(Flux.range(1, 5))
.thenRequest(2)
.expectNext(1)
.expectNext(2)
.thenRequest(2)
.expectNext(3)
.expectNext(4)
.thenRequest(1)
.expectNext(5)
.expectComplete()
.verify();This test subscribes, requests 2 items, verifies 1 and 2 are delivered, requests 2 more, verifies 3 and 4, and so on. This is the canonical way to verify that a publisher correctly implements backpressure and does not emit more items than requested.
StepVerifier also supports expectError(), expectTimeout(), and consumeNextWith() for more complex scenarios. Using thenRequest() explicitly catches off-by-one errors and hidden buffering that would otherwise go unnoticed in an unbounded subscriber.
Practical patterns and anti-patterns
Pattern: Bounded queues with prefetch. Set a prefetch size that matches your processing latency. Too small (1, 8) and you lose throughput. Too large (10000) and you risk memory buildup. Aim for 32–256 depending on item size and downstream latency.
Pattern: Overflow strategy per source. For wrapped callbacks or imperative sources, choose BUFFER for data integrity (database cursors), DROP for best-effort sampling (metrics), LATEST for state updates (config). Do not default to BUFFER everywhere.
Anti-pattern: Ignoring backpressure by requesting Long.MAX_VALUE. If you do this, you are saying 'I will consume unbounded data' and the publisher will happily emit millions of items into your subscriber's buffer, eventually causing OutOfMemoryError.
Anti-pattern: Blocking inside onNext(). If your subscriber calls a blocking operation in onNext() (e.g., Thread.sleep(), a blocking database call), the publisher is blocked too, and backpressure stalls the entire chain. Use publishOn() to move blocking work to a separate thread pool instead.
Pattern: Test with bounded demand. Use StepVerifier.thenRequest() or a custom Subscriber that controls request size. This catches off-by-one bugs and hidden buffering in operators. Do not just subscribe() and assume backpressure works.
Long.MAX_VALUE sparingly, test with StepVerifier.thenRequest(), and never block inside onNext(). Project Reactor and RxJava Flowable implement this fully; java.util.concurrent.Flow is the standard JDK interface.