Reactive Streams: Publisher, Subscriber, request(n) and backpressure

What it is

Reactive Streams is a four-interface specification for asynchronous stream processing with non-blocking backpressure. It is not a library. It is a contract that libraries implement so they can interoperate: Project Reactor, RxJava, Akka Streams, Vert.x, the MongoDB and R2DBC drivers, and since Java 9 the JDK itself as java.util.concurrent.Flow.

The four interfaces are small enough to state in full:

public interface Publisher<T> {
    void subscribe(Subscriber<? super T> s);
}

public interface Subscriber<T> {
    void onSubscribe(Subscription s);
    void onNext(T t);
    void onError(Throwable t);
    void onComplete();
}

public interface Subscription {
    void request(long n);      // the entire point of the specification
    void cancel();
}

public interface Processor<T, R> extends Subscriber<T>, Publisher<R> { }

request(n) is why the specification exists. Without it you have observer-pattern callbacks, which is what RxJava 1 and most callback APIs are: the producer pushes and the consumer copes. With it, the consumer tells the producer how many elements it is prepared to receive, and the producer is contractually forbidden from sending more. That inverts control of rate while keeping the push model for delivery, which is the trick.

What it is confused with: backpressure is not buffering, and it is not throttling. Buffering absorbs a rate mismatch until memory runs out. Throttling discards or delays at the consumer. Backpressure propagates the constraint upstream, so the original producer slows down. That is a different mechanism with a different failure mode: a correctly backpressured pipeline slows down, and a buffered one falls over.

The problem it solves

A fast producer and a slow consumer, connected asynchronously. Three options existed before Reactive Streams and all three are bad:

Block the producer. This is what a synchronous InputStream does and it works perfectly: the producer cannot outrun the consumer because it is on the same thread. The cost is a thread per stream, and threads are expensive enough that this is what you are trying to escape.

Buffer without bound. The producer pushes into a queue, the consumer drains it. Under sustained overload the queue grows until the process dies with an OutOfMemoryError, and it dies at the point of maximum load, which is when you can least afford it. This is the default failure mode of naive callback-based code.

Drop. Bounded buffer, discard on overflow. Fine for a stock ticker where only the latest value matters, catastrophic for orders.

Backpressure is the fourth option: tell the producer. The consumer signals capacity, the producer respects it, and if the producer is itself reading from somewhere (a TCP socket, a database cursor, a Kafka topic) the constraint propagates all the way to the source. A slow database write eventually stops reading from the socket, and TCP's own flow control then slows the remote sender. The pipeline becomes rate-matched end to end without anyone writing rate-limiting code.

Mechanics

The protocol, traced

Subscriber                          Publisher
    │                                   │
    │──────── subscribe(this) ─────────▶│
    │                                   │
    │◀────── onSubscribe(sub) ──────────│    (always first, exactly once)
    │                                   │
    │──────── sub.request(3) ──────────▶│    "I can handle 3"
    │                                   │
    │◀──────── onNext(a) ───────────────│
    │◀──────── onNext(b) ───────────────│
    │◀──────── onNext(c) ───────────────│    exactly 3, never 4
    │                                   │
    │      (consumer does work)         │
    │                                   │
    │──────── sub.request(2) ──────────▶│    "2 more"
    │◀──────── onNext(d) ───────────────│
    │◀──────── onNext(e) ───────────────│
    │◀──────── onComplete() ────────────│    terminal, exactly once

The rules that make this composable, from the specification:

  • Signals are serial. onNext calls never overlap; the subscriber does not need synchronisation for its own state.
  • onSubscribe is first, exactly once, before any onNext.
  • onComplete or onError is last, exactly once, and nothing follows it. In particular, after onError the subscription is dead and there is nothing to cancel.
  • request(n) is additive and unbounded in aggregate. Requesting 3 then 2 means the publisher may emit 5. request(Long.MAX_VALUE) means "unbounded, I am not backpressuring," which is a legitimate choice and is what happens when you use a reactive library without thinking about it.
  • The publisher must not emit more than requested, and violating this is the one bug that makes the whole specification worthless.

Writing a subscriber that actually backpressures

public class BatchSubscriber implements Subscriber<Order> {
    private static final int BATCH = 100;
    private Subscription subscription;
    private final List<Order> buffer = new ArrayList<>(BATCH);

    @Override public void onSubscribe(Subscription s) {
        this.subscription = s;
        s.request(BATCH);                 // NOT Long.MAX_VALUE
    }

    @Override public void onNext(Order order) {
        buffer.add(order);
        if (buffer.size() == BATCH) {
            writeToDatabase(buffer);      // slow, synchronous, bounded
            buffer.clear();
            subscription.request(BATCH);  // ask for the next batch AFTER the work
        }
    }

    @Override public void onError(Throwable t) { alert(t); }

    @Override public void onComplete() {
        if (!buffer.isEmpty()) writeToDatabase(buffer);
    }
}

The ordering in onNext carries the whole mechanism: request more only after the work is done. Requesting at the top of onNext means the publisher can emit while you are still writing, and you have silently reverted to an unbounded push.

request(BATCH) rather than request(Long.MAX_VALUE) is the other half. A great deal of reactive code requests unbounded because that is what the convenience operators do by default, and then reports that "backpressure does not work." It works; it was turned off.

The overflow strategies, and when each is right

Some sources cannot be backpressured. A MouseEvent listener, a message broker pushing at its own rate, a sensor: you cannot tell them to slow down. Reactor exposes this as Flux.create with an overflow strategy, and choosing one is a product decision, not a technical one.

StrategyBehaviour on overflowRight when
BUFFERUnbounded queueBursts are short and bounded; you have measured the worst case
DROPDiscard the newestLatest-value semantics: prices, positions, gauges
LATESTKeep only the most recentSame, but you always want the freshest
ERRORSignal OverflowExceptionCorrectness matters; you want to fail loudly
IGNOREDo nothing; downstream may violate the specEssentially never
Flux<Tick> ticks = Flux.create(sink -> {
    exchange.onTick(sink::next);              // cannot be slowed down
}, FluxSink.OverflowStrategy.LATEST);         // a stale price is worthless anyway

BUFFER is the default in several APIs and it is the one that kills processes, because "unbounded queue" means the failure appears as an OOM in an unrelated part of the application, minutes after the actual overload. If you choose BUFFER, use the bounded form (onBackpressureBuffer(1000, dropped -> metric.increment())) so the overflow is visible and attributable.

Backpressure crossing an async boundary

Operators that move work between threads are where request accounting becomes concrete, because they must buffer.

Flux.range(1, 1_000_000)
    .publishOn(Schedulers.boundedElastic(), 64)   // prefetch of 64, not the default 256
    .map(this::expensiveTransform)
    .subscribe(new BatchSubscriber());

publishOn's second argument is the prefetch: how many elements it requests upstream to keep its internal queue full. The default is 256, and it uses a 75 percent replenishment rule: when 75 percent of the prefetch has been consumed, it requests that many more. This is the mechanism that turns a downstream request(100) into a sensible upstream request pattern rather than 100 individual requests.

The number matters for memory: prefetch times element size times the number of concurrent async boundaries. A pipeline with three publishOn calls at the default 256, processing 10 KB elements, holds up to 7.7 MB in flight per subscription. With 5,000 concurrent subscriptions that is 38 GB, which is how a "non-blocking" service runs out of memory.

TCP flow control: the free part

The strongest argument for end-to-end backpressure is that it connects to a mechanism you already have. A WebFlux handler writing a Flux to an HTTP response goes through Reactor Netty, which writes into the socket. When the socket's send buffer is full, Netty stops requesting from the Flux, which stops requesting from the database driver, which stops fetching rows.

slow client (2G phone)
   → TCP receive window shrinks
   → server socket send buffer fills
   → Netty channel not writable
   → Netty stops calling request(n)
   → Flux stops emitting
   → R2DBC driver stops fetching rows
   → database cursor pauses

A slow client pauses a database cursor, and nobody wrote a line of code to make that happen. That is the payoff for the whole specification, and it is the thing to describe when asked why any of this is worth the complexity. The equivalent blocking stack would have a thread parked in write() holding a database connection for the duration.

A worked example: an OOM that a bounded request fixed

A service exporting an account's full transaction history as a CSV download. Some accounts have millions of rows. Written with WebFlux and R2DBC, so on paper it was streaming end to end.

@GetMapping(value = "/export/{accountId}", produces = "text/csv")
public Flux<String> export(@PathVariable String accountId) {
    return repository.findByAccountId(accountId)      // Flux<Transaction>
        .map(this::toCsvLine)
        .collectList()                                 // <-- the bug
        .flatMapMany(Flux::fromIterable);
}

collectList() materialises the entire stream into memory before emitting anything. It is a perfectly legitimate operator that happened to be wrong here, added during a refactor to make a test easier to write, and it converted a streaming pipeline into a buffering one while leaving every type signature reactive.

account with 4.1M transactions
  4.1M Transaction objects, ~280 bytes each = 1.15 GB
  + 4.1M CSV Strings, ~140 bytes each       = 574 MB
  = ~1.7 GB heap for one request

Three concurrent large exports on a 4 GB heap produced an OOM. The symptom that made it hard to diagnose: the OOM did not happen in the export handler. It happened wherever the next allocation occurred, which was usually an unrelated endpoint, so the stack traces pointed at innocent code.

The fix, in stages, with what each one bought:

@GetMapping(value = "/export/{accountId}", produces = "text/csv")
public Flux<DataBuffer> export(@PathVariable String accountId) {
    return repository.findByAccountId(accountId)
        .map(this::toCsvLine)
        .map(line -> bufferFactory.wrap(line.getBytes(UTF_8)))
        .limitRate(256);         // explicit demand; do not let anything request unbounded
}

plus setting the R2DBC driver's fetch size so the database cursor itself is bounded:

// Postgres R2DBC: without this the driver may fetch the whole result set.
connectionFactory = ConnectionFactories.get(builder()
    .option(FETCH_SIZE, 500)
    .build());

Measured, exporting a 4.1M-row account:

                          before            after
peak heap for 1 export    1.7 GB            41 MB
time to first byte        94s               210ms
total export time         112s              87s
concurrent exports on
  a 4 GB heap             2 (3rd OOMs)      60+ (network-bound)
GC time during export     31% of wall       1.2%

Time to first byte went from 94 seconds to 210 milliseconds, which mattered more to users than anything else: the old version looked hung, and several users had been retrying, multiplying the load that caused the OOM.

Total time also improved, by 22 percent, and the reason is instructive: the old version spent 31 percent of wall-clock time in GC because it was allocating 1.7 GB of short-lived objects. The streaming version's objects die in the young generation immediately.

The limitRate(256) is the line that encodes the lesson. Without it, Reactor Netty's write path requests generously, the R2DBC driver obliges, and rows accumulate in the gap between the two, so you can reintroduce the same failure with no collectList() anywhere in sight. Backpressure is not automatic just because the types are reactive.

Production evidence

The specification was co-developed by engineers from Netflix (RxJava), Lightbend (Akka), Pivotal (Reactor), Oracle and Red Hat, specifically so these libraries could interoperate. That provenance is the reason it is four interfaces and a rule document rather than a framework: nobody would have adopted a competitor's framework.

Java 9 adopted it verbatim as java.util.concurrent.Flow, with identical method signatures, which is why Reactor and RxJava can bridge to the JDK types with an adapter that does nothing but change the package name. Adoption into the JDK is unusually strong evidence that a specification is settled.

The TCK (Technology Compatibility Kit) ships with the specification and is the practical enforcement: an implementation claiming Reactive Streams compliance runs about 70 tests covering the rules above, particularly the "must not emit more than requested" rule. Implementations fail these tests regularly during development, which is a useful data point about how hard the contract is to satisfy by accident.

R2DBC, the reactive database specification, exists because a Flux over JDBC is a lie: JDBC blocks, so a reactive wrapper over it just moves the blocking to a different thread pool without providing backpressure to the database. R2DBC drivers implement demand-driven fetching so request(n) reaches the cursor, which is the only way the end-to-end story above works.

Netflix's original RxJava motivation was API composition for their device endpoints, and the backpressure work in RxJava 2 (which split Observable without backpressure from Flowable with it) was driven by exactly the OOM failure mode described here. That split is instructive: they concluded backpressure is not free and should not be imposed on sources that cannot support it.

The debate

Is Reactive Streams worth the complexity now that virtual threads exist? This is the live question and it deserves a direct answer. Virtual threads (Java 21) make the blocking model cheap again: a thread per request costs a few hundred bytes, so the original reason to go reactive (thread scarcity) is largely gone. See virtual threads vs reactive for the full argument.

What virtual threads do not give you is backpressure. A blocking pipeline with virtual threads backpressures naturally within one call chain, because the thread blocks. It does not give you composable, non-blocking rate propagation across asynchronous boundaries, fan-out, or merged streams. If your service is request-response, virtual threads are simpler and I would choose them. If you are building a streaming data pipeline, or fanning out to many sources with different rates, or streaming a large response to a slow client, the demand protocol is doing real work that blocking does not replicate.

My position: reactive for streams, blocking-with-virtual-threads for requests. The mistake to avoid is the one many teams made in 2018 to 2022, which is adopting reactive for a CRUD service to get throughput they could have had from a larger thread pool, and paying for it in debuggability forever.

Should you implement Publisher yourself? Almost never. The specification's rules about serial signals, request accounting under concurrent request and cancel, and terminal-signal ordering are genuinely hard to satisfy, and the TCK exists because implementations get them wrong. Use Flux.create, Flux.generate, or Mono.fromFuture and let the library handle the accounting. The exception is writing a driver or an integration for a protocol nobody has covered, and then run the TCK.

Is unbounded request(Long.MAX_VALUE) always wrong? No. When the source is already bounded and small (a list, a fixed query result), or when the consumer is genuinely faster than the producer, unbounded demand removes per-batch request overhead and is the right default. The library operators use it deliberately for this reason. It is wrong when the source is large or unbounded and the consumer is slower, which is precisely the case people fail to notice.

Follow-up Q&A

"What is backpressure, in one sentence?"

The consumer tells the producer how many elements it can accept, and the producer is contractually forbidden from sending more, so a rate mismatch slows the producer down instead of filling a queue. The contrast that makes it concrete: buffering absorbs a mismatch until memory runs out, and backpressure propagates the constraint upstream to the original source, which might be a TCP socket or a database cursor.

"What does request(n) actually do?"

It grants the publisher permission to emit up to n more elements. Grants are additive, so request(3) then request(2) permits 5 total. The publisher must never exceed the outstanding grant, and that single rule is what the entire specification protects. request(Long.MAX_VALUE) means unbounded, which is how you turn backpressure off, usually by accident.

"Where do you put the request call in a subscriber?"

After the work, not before. Requesting at the start of onNext lets the publisher emit while you are still processing, which reintroduces unbounded push. The batching subscriber pattern is: accumulate, and when the batch is full, do the work, then request the next batch.

"You have a source that cannot be slowed down. Now what?"

You cannot backpressure it, so you must choose a lossy or bounded strategy and the choice is a product decision. LATEST or DROP for latest-value data like prices and gauges, where a stale element is worthless anyway. ERROR when correctness matters and you would rather fail loudly than silently lose data. Bounded BUFFER with a drop callback and a metric when short bursts are expected and you have measured the worst case. What I would not do is unbounded BUFFER, because the failure surfaces as an OOM in unrelated code minutes later.

"Your reactive service OOMs. Where do you look?"

For an operator that materialises: collectList, collect, toIterable, block, cache, buffer without a bound, groupBy with high cardinality (it holds a state machine per group). Then for unbounded demand: something calling request(Long.MAX_VALUE), or an onBackpressureBuffer with no size. Then prefetch accounting: number of async boundaries times prefetch times element size times concurrent subscriptions, which is the calculation nobody does and which is often the answer. And finally the source: an R2DBC or driver fetch size that pulls the whole result set regardless of what the reactive layer requests.

"How does backpressure reach the database?"

Only if every layer participates. R2DBC drivers implement demand-driven fetching, so a request(n) from the reactive chain becomes a bounded fetch against the cursor. JDBC cannot do this, because it blocks, so wrapping JDBC in a Flux gives you reactive types with no reactive semantics: the driver fetches at its own rate onto a thread pool. That is the single most common way a "reactive" stack has no backpressure at all.

Common misconceptions

"Reactive means fast." It means non-blocking and rate-controlled. Throughput per core is often similar to a well-tuned blocking stack, and latency is frequently slightly worse because of the queueing and scheduling between stages. The wins are memory per concurrent connection and behaviour under overload.

"Using Flux and Mono gives me backpressure." It gives you the types. If any stage requests unbounded, or an operator materialises, or the driver fetches eagerly, you have reactive types over a buffering pipeline. The collectList in the worked example is the canonical case.

"Backpressure prevents overload." It propagates overload upstream to the source, where it becomes someone else's problem in a controlled way. The source still has to do something, whether that is blocking, dropping, or slowing an upstream producer. Backpressure moves the decision to where it can be made correctly; it does not make the load disappear.

"onError can be recovered from by resubscribing to the same subscription." The subscription is terminated. Retry operators work by resubscribing to the publisher, creating a new subscription, which means the source must be replayable. For a non-replayable source, retry() silently does nothing useful.

"Bigger buffers are safer." Bigger buffers hide the problem longer and make it worse when it arrives, because you have more in flight to lose and a longer queue to drain. A small bounded buffer with a visible overflow metric tells you about the mismatch while you can still act on it. This is the same argument as bounded queues in thread pools.

Interview delivery note

Say this verbatim: "Backpressure is the consumer telling the producer how much it can take, so a rate mismatch slows the producer instead of filling a queue. The payoff is that it composes all the way to the source: a slow HTTP client stops Netty requesting, which stops the database driver fetching, which pauses the cursor, and nobody wrote code for that." The end-to-end chain is the concrete thing, and it is what separates understanding the mechanism from reciting the interfaces.

The senior-versus-staff separator is knowing that reactive types do not imply backpressure. A senior engineer explains request(n) correctly. A staff engineer points out that a collectList() in the middle, or a JDBC driver at the bottom, or an operator requesting Long.MAX_VALUE, gives you reactive types over a buffering pipeline, and that the resulting OOM surfaces in unrelated code. Being able to name the specific operators that materialise is the checkable version of that knowledge.

The second signal is the prefetch arithmetic: async boundaries times prefetch times element size times concurrent subscriptions. It is the memory calculation nobody does for reactive services, and doing it is how you size one.

Further reading

  • The Reactive Streams specification (reactive-streams.org), which is short enough to read completely, plus the TCK's rule numbering for the exact contract.
  • Project Reactor reference guide, "Backpressure and the associated rules of Reactive Streams," and the operator documentation for limitRate, onBackpressureBuffer and publishOn prefetch.
  • R2DBC specification, on demand-driven fetching, for why JDBC cannot provide backpressure to the database.
  • David Karnok's blog series on operator-fusion and request accounting, for how implementations actually satisfy the contract efficiently.