Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 015 / 100

CompletableFuture basics: a hands-on guide for engineers

Run independent calls in parallel and combine the results.

Expona Admin7 min read

Why CompletableFuture matters in production systems

When your server needs to fetch a user profile and their recent orders before rendering a page, waiting for each call to finish sequentially wastes precious milliseconds. CompletableFuture basics: a hands-on guide for engineers teaches you to run independent calls in parallel and combine the results—exactly the pattern we use across our microservices handling millions of requests daily. Learning to compose asynchronous work without blocking threads is no longer optional in modern backend systems.

CompletableFuture, introduced in Java 8, lets you build pipelines of async work with clean, declarative syntax. Unlike raw callbacks or threads, it handles thread pooling, exception propagation, and result composition for you. We'll walk through the core patterns you need to write production-grade async code.

supplyAsync starts work on an executor

The foundation of CompletableFuture is supplyAsync, which takes a function and runs it asynchronously. The key detail most engineers miss: always pass your own executor in servers. The default ForkJoinPool works fine for CLI tools, but in a web server, it starves your application threads.

Consider a service that fetches user data:

java
ExecutorService pool = Executors.newFixedThreadPool(10);

CompletableFuture<User> user = CompletableFuture.supplyAsync(
    () -> userRepository.findById(userId),
    pool
);

supplyAsync immediately returns a CompletableFuture without blocking. The actual work happens on a thread from pool. If you omit the executor argument, Java uses the common ForkJoinPool, which is shared across your entire JVM and can become a bottleneck under load.

A real-world pattern in our order microservice looks like this:

java
ExecutorService ioPool = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors() * 2,
    new ThreadFactory() {
        private final AtomicInteger count = new AtomicInteger();
        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r);
            t.setName("async-io-" + count.incrementAndGet());
            return t;
        }
    }
);

var userFuture = CompletableFuture.supplyAsync(
    () -> users.find(id),
    ioPool
);

var ordersFuture = CompletableFuture.supplyAsync(
    () -> orders.forUser(id),
    ioPool
);

Both calls start immediately on separate threads. The userFuture and ordersFuture run in parallel, not in sequence. If user lookup takes 50ms and orders lookup takes 80ms, the total time is ~80ms, not 130ms.

thenCombine joins two results

Once you have two independent futures, you often need to merge their results. thenCombine waits for both to complete, then applies a function to combine them.

java
var page = user.thenCombine(
    orders,
    (userData, orderData) -> new Profile(userData, orderData)
);

This reads naturally: "when user completes and orders completes, combine them into a Profile." The combining function runs on whichever thread finishes last (or a thread from the pool, depending on timing). If either user or orders throws an exception, thenCombine propagates it and never calls your combining function.

Here's how we structure a real profile-fetch endpoint:

java
public CompletableFuture<ProfileResponse> getProfile(String userId) {
    var userFuture = CompletableFuture.supplyAsync(
        () -> userRepository.findById(userId),
        ioPool
    );

    var ordersFuture = CompletableFuture.supplyAsync(
        () -> orderRepository.findByUserId(userId),
        ioPool
    );

    var preferencesFuture = CompletableFuture.supplyAsync(
        () -> preferenceService.getPreferences(userId),
        ioPool
    );

    return userFuture.thenCombine(
        ordersFuture,
        (user, orders) -> new UserWithOrders(user, orders)
    ).thenCombine(
        preferencesFuture,
        (userAndOrders, prefs) -> new ProfileResponse(
            userAndOrders.getUser(),
            userAndOrders.getOrders(),
            prefs
        )
    );
}

Chaining thenCombine works, but gets verbose with three or more futures. For those cases, allOf is cleaner.

allOf waits for many futures

When you need to coordinate many independent async operations, allOf waits for all of them to complete. Unlike thenCombine, which returns the combined result, allOf returns Void. You manually collect results afterward.

java
var user = CompletableFuture.supplyAsync(
    () -> userRepository.findById(userId),
    ioPool
);

var orders = CompletableFuture.supplyAsync(
    () -> orderRepository.findByUserId(userId),
    ioPool
);

var preferences = CompletableFuture.supplyAsync(
    () -> preferenceService.getPreferences(userId),
    ioPool
);

var invoices = CompletableFuture.supplyAsync(
    () -> invoiceRepository.findByUserId(userId),
    ioPool
);

CompletableFuture.allOf(user, orders, preferences, invoices)
    .thenApply(__ -> new ProfileResponse(
        user.join(),
        orders.join(),
        preferences.join(),
        invoices.join()
    ))
    .join();

Notice the join() calls inside thenApply. By the time that function runs, all four futures have completed, so join() returns immediately without blocking. This pattern scales well—you can wait for 5, 10, or 50 futures with the same code.

In a real dashboard endpoint, we fetch user stats, recent transactions, account balance, and security settings in parallel:

java
public DashboardData loadDashboard(String userId) {
    var userFuture = CompletableFuture.supplyAsync(
        () -> userService.getUser(userId),
        ioPool
    );
    var statsFuture = CompletableFuture.supplyAsync(
        () -> analyticsService.getUserStats(userId),
        ioPool
    );
    var transactionsFuture = CompletableFuture.supplyAsync(
        () -> transactionService.getRecent(userId, 10),
        ioPool
    );
    var balanceFuture = CompletableFuture.supplyAsync(
        () -> accountService.getBalance(userId),
        ioPool
    );

    return CompletableFuture.allOf(
        userFuture, statsFuture, transactionsFuture, balanceFuture
    ).thenApply(__ -> new DashboardData(
        userFuture.join(),
        statsFuture.join(),
        transactionsFuture.join(),
        balanceFuture.join()
    )).join();
}

All four I/O operations run concurrently. If each takes 50ms, the total is ~50ms instead of 200ms.

Always pass your own executor in servers

This cannot be overstated: in a server environment, never use CompletableFuture without an explicit executor. The default behavior uses the common ForkJoinPool, which is thread-starved under load.

Consider a Tomcat server with 200 HTTP handler threads. If you call supplyAsync without an executor, work queues on the ForkJoinPool (typically 8–16 threads). The ForkJoinPool gets congested. Meanwhile, your 200 HTTP threads are blocked in join() calls, waiting for work that's stuck in the queue. Your server becomes unresponsive.

The fix is straightforward:

java
@Configuration
public class AsyncConfig {
    @Bean
    public ExecutorService ioExecutor() {
        int poolSize = Runtime.getRuntime().availableProcessors() * 2;
        return Executors.newFixedThreadPool(
            poolSize,
            new ThreadFactory() {
                private final AtomicInteger count = new AtomicInteger();
                @Override
                public Thread newThread(Runnable r) {
                    Thread t = new Thread(r);
                    t.setName("async-io-" + count.incrementAndGet());
                    t.setDaemon(false);
                    return t;
                }
            }
        );
    }
}

@Service
public class ProfileService {
    private final ExecutorService executor;

    public ProfileService(@Qualifier("ioExecutor") ExecutorService executor) {
        this.executor = executor;
    }

    public CompletableFuture<Profile> getProfile(String userId) {
        return CompletableFuture.supplyAsync(
            () -> userRepository.findById(userId),
            executor
        ).thenCombine(
            CompletableFuture.supplyAsync(
                () -> orderRepository.findByUserId(userId),
                executor
            ),
            Profile::new
        );
    }
}

By default, size the thread pool to 2× the number of CPU cores. For I/O-bound work (database, HTTP calls), this allows threads blocked on I/O to be replaced by others. Monitor thread count and queue depth in production—if queues grow, increase pool size.

Common mistakes

Calling join() in the wrong place: Calling join() immediately after supplyAsync defeats the entire purpose of async work. You'll block the caller thread waiting for the result.

java
// Wrong: defeats parallelism
var user = CompletableFuture.supplyAsync(() -> users.find(id), pool).join();
var orders = CompletableFuture.supplyAsync(() -> orders.forUser(id), pool).join();

Forgetting exception handling: If any step throws an exception, it propagates downstream and you won't see the result. Use exceptionally or handle to recover:

java
user.thenCombine(orders, Profile::new)
    .exceptionally(ex -> {
        log.error("Failed to load profile", ex);
        return fallbackProfile();
    });

Not sizing the executor properly: Too small and you get queue buildup. Too large and you waste memory on idle threads. Start at 2× CPU cores and adjust based on observed queue depth and response times.

Using thenApply for blocking work: thenApply runs on the same thread that completed the prior stage (unless you use thenApplyAsyncthenApply, you'll block that executor thread. Use thenApplyAsync or supplyAsync for I/O.

Key takeaways

  • supplyAsync(task, executor) launches async work on a thread from your executor—never omit the executor in server code.
  • thenCombine(other, fn) waits for two futures and merges their results with a function.
  • allOf(future1, future2, ...) waits for many futures to complete; collect results by calling join() after allOf completes.
  • Size your executor pool to 2× available processors for I/O-bound work; monitor queue depth and adjust in production.
  • Exception handling is automatic—if any future fails, the exception propagates downstream.
  • Call join() at the boundary where you need the result (e.g., at the end of a handler), not inside chains where it defeats parallelism.
  • Avoid blocking operations inside thenApply; use thenApplyAsync or supplyAsync to offload I/O to the executor pool.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles