Field note / spring-webflux

publishedupdated 2026-05-01#reactive-streams#project-reactor#backpressure#publisher-subscriber#schedulers

Reactive Streams & Project Reactor

The Reactive Streams specification, Project Reactor's Publisher/Subscriber model, backpressure, cold vs hot publishers, and the threading model with Schedulers.

Overview

Reactive Streams is a specification (not an implementation) that standardizes async, non-blocking stream processing with backpressure. Project Reactor is Spring's implementation, providing Mono<T> (0-1 elements) and Flux<T> (0-N elements). The core value: a single thread can handle thousands of concurrent I/O operations by never blocking — it yields and resumes via callbacks, not threads.


Architecture

REACTIVE STREAMS SPECIFICATION
════════════════════════════════════════════════════════

  Publisher<T>  ──subscribe(Subscriber)──▶  Subscriber<T>
                                                 │
                                          onSubscribe(Sub)
                                                 │
                        ◀─── request(n) ─────────┘ ← BACKPRESSURE
                        │
               onNext(item) × n
               onNext(item)
               onComplete() or onError()

  Contract:
  1. Subscriber requests N items (backpressure signal)
  2. Publisher sends AT MOST N items
  3. Ends with onComplete OR onError, never both
  4. onNext never called after terminal signals

COLD vs HOT PUBLISHERS
════════════════════════════════════════════════════════

  COLD Publisher:
  ├── Starts producing when subscribed
  ├── Each subscriber gets its own stream from the beginning
  ├── Examples: HTTP request, DB query, file read
  └── Flux.fromIterable(), Mono.fromCallable()

  HOT Publisher:
  ├── Produces regardless of subscribers
  ├── Subscribers join mid-stream (miss past events)
  ├── Examples: real-time events, stock prices, WebSocket feed
  └── Flux.create() with SinkType.MULTICAST

PROJECT REACTOR THREADING MODEL
════════════════════════════════════════════════════════

  Default: Everything runs on subscribe() thread (often main or HTTP worker)

  publishOn(scheduler)  → switches downstream operators to scheduler thread
  subscribeOn(scheduler) → switches subscription and upstream to scheduler thread

  Schedulers.boundedElastic()  → for blocking I/O (JDBC, file system)
                                  auto-scales thread pool, queue for excess
  Schedulers.parallel()        → for CPU work (N threads = N CPUs)
  Schedulers.single()          → single non-daemon thread (timer, coordination)
  Schedulers.immediate()       → current thread (default, no switch)

Technical Implementation

Backpressure with Custom Subscriber

// Manual subscriber demonstrating backpressure control
Flux.range(1, 1000)
    .subscribe(new BaseSubscriber<Integer>() {

      @Override
      protected void hookOnSubscribe(Subscription subscription) {
        request(10);  // request first batch of 10 items
      }

      @Override
      protected void hookOnNext(Integer value) {
        System.out.println("Received: " + value);
        if (value % 10 == 0) {
          request(10);  // request next batch when current batch processed
        }
      }

      @Override
      protected void hookOnError(Throwable ex) {
        System.err.println("Error: " + ex.getMessage());
      }

      @Override
      protected void hookOnComplete() {
        System.out.println("Done");
      }
    });

Schedulers: Mixing Reactive and Blocking Code

@Service
public class UserService {

  // WRONG: blocking call on reactive scheduler thread → starves thread pool
  public Mono<User> findUserBad(String id) {
    return Mono.just(jdbcRepo.findById(id));  // JDBC blocks the reactor thread!
  }

  // CORRECT: offload blocking work to boundedElastic thread pool
  public Mono<User> findUser(String id) {
    return Mono.fromCallable(() -> jdbcRepo.findById(id))  // wraps blocking call
               .subscribeOn(Schedulers.boundedElastic());   // execute on I/O thread pool
  }

  // CPU-bound work on parallel scheduler
  public Mono<byte[]> hashPassword(String raw) {
    return Mono.fromCallable(() -> BCrypt.hashpw(raw, BCrypt.gensalt(12)))
               .subscribeOn(Schedulers.parallel());
  }

  // Switching thread contexts in a pipeline
  public Flux<UserSummary> getEnrichedUsers() {
    return Flux.fromIterable(getIds())               // thread: subscriber's thread
               .publishOn(Schedulers.boundedElastic()) // switch to I/O pool here
               .flatMap(id -> Mono.fromCallable(
                   () -> jdbcRepo.findById(id)       // runs on boundedElastic
               ))
               .publishOn(Schedulers.parallel())     // switch to CPU pool here
               .map(user -> toSummary(user));         // CPU work on parallel
  }
}

Creating a Hot Publisher (Event Stream)

// Hot publisher: emits to all current subscribers
// New subscribers miss past events
Sinks.Many<OrderEvent> sink = Sinks.many().multicast().onBackpressureBuffer(256);
Flux<OrderEvent> hotStream = sink.asFlux();

// Producer — emits events whenever they occur
void publishOrderEvent(OrderEvent event) {
  Sinks.EmitResult result = sink.tryEmitNext(event);
  if (result.isFailure()) {
    log.warn("Failed to emit event: {}", result);
  }
}

// Multiple subscribers see the same events in real-time
hotStream.subscribe(event -> sendToWebSocket(userId1, event));
hotStream.subscribe(event -> writeToAuditLog(event));

// Convert hot to cold with replay (buffer last N events for new subscribers)
Flux<OrderEvent> replayableStream = hotStream.replay(10).autoConnect();

Interview Preparation

Q: What is backpressure and why does Reactive Streams need it?

Backpressure is a signal from the consumer to the producer to slow down. Without it: if a fast producer emits 1M items/second and a slow consumer processes 1K/second, you have two choices — buffer everything (memory explosion) or drop items (data loss). Reactive Streams solves this by making consumers explicit about capacity: subscription.request(n) tells the publisher "I can handle N items right now." The publisher must respect this. If the producer is backed by a network socket, it can stop reading (TCP receive window fills up → sender slows down). If it's a database cursor, it can pause fetching. Backpressure propagates all the way through the chain.

Q: What is the difference between publishOn and subscribeOn?

publishOn switches the downstream operators to the specified scheduler — everything after publishOn runs on that scheduler thread. subscribeOn switches the thread for the subscription chain itself — the upstream source and its initial execution run on that scheduler. In most pipelines with I/O sources, subscribeOn(Schedulers.boundedElastic()) is what you want: it moves the blocking source (JDBC, file read) onto an I/O thread, and everything downstream (operators, subscribers) runs wherever they end up. publishOn is used when you need to switch execution context mid-pipeline — e.g., do I/O on boundedElastic, then do CPU processing on parallel.

Q: What is the difference between cold and hot publishers?

A cold publisher creates a new, independent data stream for each subscriber and starts from the beginning. An HTTP call wrapped in Mono.fromCallable() is cold — each subscription makes a new HTTP request. A hot publisher exists independently of subscribers and emits events to all currently subscribed observers; late subscribers miss earlier events. A real-time WebSocket feed or an event bus is hot. In Reactor: Flux.create() with a Sink, Sinks.many().multicast(), and ConnectableFlux (from .publish()) are tools for creating hot publishers. Use .replay(n) to give new subscribers access to the last N events even though the stream is already running.


Learning Resources