⏱️ Section 19 · Question #25

When is it better to use CompletableFuture vs reactive programming

The fundamental architectural boundary between CompletableFuture and Reactive Streams:


🟢 Junior Level

Core Difference in Two Sentences

  • CompletableFuture is designed to handle a single asynchronous result (a scalar value: 0 or 1 element) within a request-response model.
  • Reactive Programming (Project Reactor, RxJava, Akka/Pekko) is engineered for continuous asynchronous processing of event streams ($0 \dots N$ or infinite elements) with built-in flow control (backpressure).
// CompletableFuture: single request — single response (1 result)
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> userService.findById(userId));
userFuture.thenAccept(user -> log.info("User loaded: {}", user.getName()));

// Project Reactor (Flux): asynchronous event stream (0..N results)
Flux<StockPrice> priceStream = stockExchangeClient.streamLivePrices("AAPL");
priceStream
    .filter(p -> p.value() > 150.0)
    .subscribe(price -> log.info("Price alert: {}", price));

Simple Rule of Thumb

  1. If you need to make 2–3 parallel asynchronous network calls, fetch database records, and combine them into a single response DTO $\rightarrow$ use CompletableFuture (standard JDK, zero third-party dependencies, easy to read and maintain).
  2. If you need stream processing (Server-Sent Events, WebSockets, telemetry feeds, reactive streaming from database drivers) or the producer generates events faster than the consumer can process them $\rightarrow$ use Reactive Programming.

🟡 Middle Level

Architectural Differences: Eager vs. Lazy, Push vs. Backpressure

1. Cardinality

  • CompletableFuture<T>: Exactly one future value or an exception. Once resolved, its state is immutable. It cannot represent a data stream over time.
  • Mono<T>: $0$ or $1$ element (the closest reactive equivalent to CompletableFuture).
  • Flux<T>: $0 \dots \infty$ elements.

2. Execution Model: Eager (Hot) vs. Lazy (Cold)

  • CompletableFuture is always Hot / Eager. The computation begins immediately upon calling CompletableFuture.supplyAsync(...) or submitting a task to an executor, regardless of whether anyone attaches callbacks via thenApply or blocks on join.
  • Reactive pipelines are Cold / Lazy by default. Declarative assembly (flux.map(...).filter(...)) executes nothing until an explicit subscriber invokes .subscribe() (or Spring WebFlux subscribes on your behalf). The stream can be re-executed independently for each new subscriber.

3. Backpressure (Flow Control)

The fundamental architectural boundary between CompletableFuture and Reactive Streams:

[Producer] ---- (100,000 items/sec) ---> [Queue] ---> [Consumer] (1,000 items/sec)
                                            |
              In CompletableFuture:         | In Reactive Streams (push-pull):
              Queue grows unbounded -> OOM! | Consumer issues request(10)
                                            | Producer throttles emission
  • In CompletableFuture, the consumer cannot tell the producer to slow down. The producer pushes tasks into a ThreadPoolExecutor. If the consumer is slower than the producer, the queue fills up, causing RejectedExecutionException or OutOfMemoryError.
  • In Reactive Streams, the Subscription.request(n) contract governs data delivery: the consumer requests only the exact batch size it has the capacity to process.

4. Cancellation Propagation

  • In CompletableFuture.cancel(mayInterruptIfRunning), cancellation is local: it marks the current node with CancellationException. The executing thread is not interrupted, and upstream stages receive no notification.
  • In Reactive Streams, calling Subscription.cancel() cascades upstream through the operator chain, terminating producers, closing underlying TCP sockets, cancelling timers, and freeing memory buffers.

Comparative Matrix

Criterion CompletableFuture Project Reactor / RxJava
Data Unit Single scalar value ($T$) Stream of events ($0 \dots \infty$)
Standard Java Core (JDK 8+) Reactive Streams Specification (Flow in Java 9)
Execution Model Hot (Eager) Cold (Lazy) by default
Backpressure None Built-in via protocol (request(n))
Cancellation Local, does not abort upstream Cascades upstream through entire chain
Operator Richness ~50 basic methods 200+ specialized operators (windows, buffers, retry)
Debugging Complexity Moderate High (detached stack traces, assembly vs execution)

🔴 Senior Level

Architecture Evolution: Reactive vs. CompletableFuture vs. Virtual Threads (Java 21+)

Historically, reactive programming was adopted in Java primarily for two reasons:

  1. Non-blocking Event Loops to avoid OS thread starvation, serving thousands of concurrent network connections on a small pool of carrier threads.
  2. Stream Composition and Backpressure for processing event feeds.

With the arrival of Java 21 (Virtual Threads / Project Loom), the trade-offs changed fundamentally:

// Traditional Spring WebFlux (Reactor):
public Mono<DashboardDto> getDashboardReactive(Long userId) {
    return userClient.getUser(userId)
        .zipWith(orderClient.getOrders(userId))
        .map(tuple -> new DashboardDto(tuple.getT1(), tuple.getT2()));
}

// Modern Java 21 (Virtual Threads + Structured Concurrency):
public DashboardDto getDashboardVirtualThreads(Long userId) throws InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Supplier<User> userSub = scope.fork(() -> userClient.getUserBlocking(userId));
        Supplier<List<Order>> ordersSub = scope.fork(() -> orderClient.getOrdersBlocking(userId));

        scope.join().throwIfFailed(); // Blocking a virtual thread costs ~1 KB of heap memory

        return new DashboardDto(userSub.get(), ordersSub.get());
    }
}

Why Virtual Threads Replace Reactive for 80% of Enterprise Workloads

  1. Preservation of Imperative Semantics: Standard try-catch, standard loops, standard IDE debuggers, familiar ThreadLocal storage, and clear stack traces work seamlessly without reactive wrappers.
  2. Elimination of the Function Coloring Problem: In reactive programming, returning Mono or Flux infects all architectural layers from controller to persistence. With Virtual Threads, every method remains a clean, standard synchronous call.

Where Reactive Programming Remains Irreplaceable

Virtual threads make waiting on blocking I/O inexpensive, but they do not manage data flow rates. Reactive programming remains superior for:

  1. Backpressure in high-throughput gateways (API gateways, rate limiters, memory-constrained message ingestion).
  2. Complex Temporal Event Mathematics: Sliding time windows (window(Duration)), debouncing rapid events (debounce), sampling, and continuous real-time market data streaming.
  3. Long-Lived Duplex Streaming: Server-Sent Events (SSE), WebSockets, and bidirectional gRPC streams.

4 Tricky Questions

1. What happens if a data source emits 1,000,000 items per second into CompletableFuture vs. a Reactive Stream?

Answer: In a CompletableFuture architecture, each item typically requires dispatching a task to an executor thread pool (e.g., via thenApplyAsync). Because the consumer cannot signal the producer to slow down (no backpressure), the thread pool queue grows unbounded. This inevitably triggers either a RejectedExecutionException or an OutOfMemoryError: Java heap space.

In a Reactive Stream (Flux / Flow.Publisher), the consumer initially requests only what it can comfortably buffer (e.g., request(32)). The producer pauses generation or suspends reading from the network socket at the TCP receive buffer level until the consumer finishes processing and issues the next request. If dealing with an uncontrollable hot stream (hardware sensors), the reactive pipeline allows explicit backpressure overflow strategies: onBackpressureDrop() (drop excess), onBackpressureLatest() (retain only the freshest value), or bounded ring buffers.

2. Is CompletableFuture considered a “hot” or “cold” source compared to Project Reactor Mono?

Answer: CompletableFuture is strictly Hot (Eager). It represents a promise for a computation that is already underway (or finished). When CompletableFuture.supplyAsync(task) is called, the task is immediately scheduled on an executor regardless of whether any subscriber registers callbacks. If multiple downstream stages invoke .thenApply(), the root task runs exactly once, sharing its cached result across all subscribers.

In contrast, Mono in Project Reactor is Cold (Lazy) by default. Calling Mono.fromCallable(task) or Mono.defer(...) merely constructs an execution recipe. If no subscriber calls .subscribe(), the task never runs. If three separate consumers subscribe to the same cold Mono, the underlying operation runs three separate times (once per subscription), unless explicitly converted to a hot stream via .share() or .publish().autoConnect().

3. How does cancellation propagate in CompletableFuture vs. Reactive Streams? Can a running HTTP request be aborted?

Answer: In CompletableFuture.cancel(true), cancellation semantics are local:

  • The boolean flag mayInterruptIfRunning is ignored by standard JDK implementations.
  • Invoking cancel() merely marks that specific stage with CancellationException. The thread running the network call or calculation is not interrupted and runs to completion.
  • No cancellation signal propagates upstream to the originating data source.

In Reactive Streams, cancellation is a first-class protocol contract. When a downstream subscriber calls Subscription.cancel(), the cancellation signal cascades upstream through the entire operator chain. Intermediate operators tear down buffers and timers, and the source client (such as Netty-based Spring WebClient) immediately terminates the underlying TCP socket channel, stopping data transmission and reclaiming OS buffers.

4. Given that Java 21 introduced Virtual Threads, do they completely render Reactive Programming and CompletableFuture obsolete?

Answer: No, not completely. Virtual threads solve Java’s historical thread-scalability constraint by making blocking I/O extremely lightweight. For traditional request-response architectures (HTTP endpoint -> query database -> call REST API -> return JSON), Virtual Threads paired with synchronous code or StructuredTaskScope make reactive frameworks an unnecessary architectural burden.

However, reactive frameworks remain irreplaceable for:

  1. Flow-Controlled Streaming (Backpressure): Virtual threads provide no protocol for consumers to rate-limit upstream data producers.
  2. Temporal Stream Manipulations: Operators like debounce, sample, bufferTimeout, and sliding windows cannot be cleanly or efficiently written using thread locks and blocking primitives.
  3. Reactive Streaming Protocols: WebSockets, Server-Sent Events, RSocket, and streaming gRPC.

🎯 Interview Cheat Sheet

Summary in 60 Seconds

  • CompletableFuture: Scalar values ($0 \dots 1$), discrete RPCs, built into JDK 8+, hot execution, no backpressure.
  • Reactive Streams (Flux / Mono): Continuous event streams ($0 \dots \infty$), cold execution by default, built-in backpressure (request(n)), upstream cancellation.
  • Backpressure Absence: In CompletableFuture, fast producers can easily cause thread pool starvation and OutOfMemoryError.
  • Java 21 Virtual Threads: Dramatically reduce the need for reactive architectures in discrete request-response microservices, but reactive remains essential for stream processing and rate regulation.

Common Interview Traps

  • Claiming that switching a CRUD application to Spring WebFlux makes individual database queries faster (WebFlux improves concurrency density under high connection load, not individual query latency).
  • Claiming CompletableFuture supports backpressure when configured with a bounded executor queue (queue rejection throws errors; it does not throttle producers).
  • Asserting that Virtual Threads eliminate the need for reactive stream processing operators like window or debounce.