Career Guide

Java Concurrency: When to Use CompletableFuture vs. Reactive Streams

Both CompletableFuture and Project Reactor enable non-blocking execution in Java. We compare their programming models, latency, resource footprints, and use cases.

As application performance demands grow, modern developers must write asynchronous, non-blocking code to maximize throughput and CPU efficiency. In the Java ecosystem, there are two primary technologies for handling asynchronous operations: CompletableFuture (introduced in Java 8) and Reactive Streams (often implemented using Project Reactor's Flux and Mono or RxJava). Both models provide tools for executing non-blocking code, but they are designed for different use cases and programming paradigms. Understanding when to use which is essential for building scalable systems.

Understanding CompletableFuture (Task-Based Concurrency)

CompletableFuture represents a single asynchronous task that will eventually produce a value (or fail). It is a push-based model designed for task-oriented concurrency.

  • Workflow: You initiate a task (often run inside Java's ForkJoinPool.commonPool() or a custom thread pool), and chain callback methods (like .thenApply(), .thenCompose(), or .thenCombine()) to process the result when it arrives.
  • Best Use Cases:

1. One-Off Async Tasks: Triggering a background task that does not affect user response times (e.g., sending an email after registration).

2. API Aggregation: Fetching data from multiple microservices concurrently and merging their responses. E.g., fetching a user profile, orders, and payment history in parallel, and combining them into a single response object.

3. Parallel Executions: Running multiple independent compute-heavy computations and waiting for all to complete via CompletableFuture.allOf().

Understanding Reactive Streams (Stream-Based Concurrency)

Reactive Streams are designed for handling asynchronous sequences of events with a pull-based model that supports backpressure. Project Reactor provides two core types:

  • Mono<T>: Represents 0 or 1 asynchronous result.
  • Flux<T>: Represents 0 to N asynchronous results (which can be an infinite stream).

Reactive Streams are built around the concept of a pipeline. Nothing happens until you subscribe to the publisher.

  • Best Use Cases:

1. Real-time Event Processing: Handling continuous streams of data from Kafka, RabbitMQ, or IoT devices.

2. High-Concurrency Gateway services: Building services like an API Gateway that routes thousands of requests concurrently using non-blocking I/O.

3. Streaming Connections: Delivering Server-Sent Events (SSE) or WebSockets where data is pushed to client devices over a long-lived connection.

Key Differences: CompletableFuture vs. Project Reactor

#### 1. Cardinality

  • CompletableFuture is strictly single-value (0 or 1).
  • Reactor's Flux is designed to handle streams of multiple values.

#### 2. Backpressure

Backpressure is a mechanism that allows a slow subscriber to signal to a fast publisher to slow down, preventing the subscriber from running out of memory.

  • CompletableFuture has no support for backpressure. If tasks are generated faster than they are consumed, they will pile up in the thread pool queue, potentially causing an OutOfMemoryError.
  • Reactive Streams natively support backpressure through the subscription model.

#### 3. Declarative Operators

  • CompletableFuture has a modest set of operations for combining and chaining tasks.
  • Reactor offers a massive set of declarative operators (such as .flatMap(), .buffer(), .window(), .retryBackoff(), and .timeout()) that simplify complex operations.

Coding Comparison

Let's look at how to combine two async sources.

First, using CompletableFuture:

public CompletableFuture<UserDetailDTO> getUserDetails(String userId) {
    CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> userService.fetchUser(userId));
    CompletableFuture<OrderHistory> orderFuture = CompletableFuture.supplyAsync(() -> orderService.fetchOrders(userId));

    return userFuture.thenCombine(orderFuture, (user, orders) -> new UserDetailDTO(user, orders));
}

Now, the same aggregation using Project Reactor:

public Mono<UserDetailDTO> getUserDetailsReactive(String userId) {
    Mono<User> userMono = userService.fetchUserReactive(userId);
    Mono<OrderHistory> orderMono = orderService.fetchOrdersReactive(userId);

    return Mono.zip(userMono, orderMono)
            .map(tuple -> new UserDetailDTO(tuple.getT1(), tuple.getT2()));
}

While they look similar for simple aggregations, Reactor becomes vastly superior when you need to add timeout fallback mechanisms, retry policies, or handle exceptions gracefully.

Threading and Resource Footprint

  • CompletableFuture: Usually relies on standard JVM thread pools (like ForkJoinPool). While the code is asynchronous, it doesn't solve the underlying blocking I/O problem. If the tasks inside the CompletableFuture perform blocking JDBC operations or block on REST calls, threads will still be held idle, consuming stack memory.
  • Reactive Streams: Integrated with non-blocking network libraries (like Netty via Spring WebFlux). It uses a small, fixed number of event loop threads. Even during millions of concurrent requests, no threads are blocked on database or API I/O, yielding massive resource savings.

How to Choose: Decision Matrix

Requirement Use CompletableFuture Use Reactive Streams
Main Focus Execute single async tasks Handle stream of events / data
Tech Stack Spring MVC (Servlet-based) Spring WebFlux (Reactive-based)
Flow Control None Full backpressure support
Complexity Low to Moderate High (Steep learning curve)
DB Access JPA / JDBC (Blocking) R2DBC (Non-blocking)

Interview Strategy for Indian Software Engineers

If you are interviewing at product companies or high-concurrency payment platforms:

  1. Discuss Backpressure: Explain that Reactive Streams' biggest selling point is flow control/backpressure, which prevents service crashes under heavy load.
  2. Acknowledge Debugging Difficulty: Reactive stack traces can be notoriously difficult to read because the execution context shifts across event loops. Explain that debugging CompletableFuture is generally easier.

📢 Share this article