Concurrent Utilities

CompletableFuture Basics

15 min Lesson 5 of 13

CompletableFuture Basics

Java 8 introduced CompletableFuture<T>, which extends the older Future<T> with something the original lacked entirely: the ability to register callbacks, chain transformations, and build full asynchronous pipelines — all without blocking a thread to wait for a result.

In this lesson we focus on two methods that form the backbone of every pipeline: supplyAsync for kicking off asynchronous work that produces a value, and thenApply for transforming that value when it arrives.

Why CompletableFuture over plain Future?

With a plain Future the only way to get the result is to call get(), which blocks the calling thread until the computation finishes. This makes pipelines impossible and defeats the point of asynchrony.

// Old-school Future — you're stuck waiting ExecutorService pool = Executors.newFixedThreadPool(4); Future<String> future = pool.submit(() -> fetchFromDatabase()); String result = future.get(); // thread blocks here — nothing else can run System.out.println(result);

CompletableFuture solves this by letting you attach a callback that fires automatically once the value is ready, freeing the thread to do other work in the meantime.

supplyAsync — starting an asynchronous computation

CompletableFuture.supplyAsync(Supplier<T>) submits a lambda to a thread pool and immediately returns a CompletableFuture<T>. The calling thread continues; the lambda runs in the background.

import java.util.concurrent.CompletableFuture; CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> { // Simulating a slow I/O operation Thread.sleep(500); // checked exception — wrap or rethrow return "order-42"; }); // The calling thread is NOT blocked; cf is already returned System.out.println("Request dispatched, doing other work...");
Which thread pool? By default supplyAsync uses the JVM-wide ForkJoinPool.commonPool(). That pool is shared across the whole application, so for production code that makes I/O calls (network, disk) you should supply your own executor as the second argument to avoid starving CPU-bound tasks.
// Providing a dedicated executor — always prefer this for I/O work ExecutorService ioPool = Executors.newFixedThreadPool(8); CompletableFuture<String> cf = CompletableFuture.supplyAsync( () -> callExternalApi(), ioPool );

thenApply — transforming the result

thenApply(Function<T, U>) registers a transformation that will run on the same thread that completed the future (or the calling thread if it's already done). It returns a new CompletableFuture<U> holding the transformed value.

CompletableFuture<Integer> pipeline = CompletableFuture .supplyAsync(() -> " hello world ") // CF<String> .thenApply(String::trim) // CF<String> — "hello world" .thenApply(String::toUpperCase) // CF<String> — "HELLO WORLD" .thenApply(String::length); // CF<Integer> — 11 System.out.println(pipeline.get()); // 11

Each thenApply call returns a new CompletableFuture. The original is unchanged. This immutable-pipeline style makes each step composable and independently testable.

Think in types. The generic parameter tells you exactly what each stage produces: CompletableFuture<String>thenApply(String::length)CompletableFuture<Integer>. If your IDE infers the types for you, you can catch logic errors (e.g. applying a String method to an Integer) at compile time.

A concrete end-to-end example

Below is a small but realistic pipeline: fetch a user ID from a slow service, look up that user's email, then format a greeting — all without blocking any thread except the final get() call at the very end.

import java.util.concurrent.*; public class PipelineDemo { static String fetchUserId() { // Simulate network latency sleep(300); return "usr-7"; } static String fetchEmail(String userId) { sleep(200); return userId + "@example.com"; } static String formatGreeting(String email) { return "Welcome, " + email + "!"; } public static void main(String[] args) throws Exception { ExecutorService pool = Executors.newFixedThreadPool(4); CompletableFuture<String> greeting = CompletableFuture .supplyAsync(PipelineDemo::fetchUserId, pool) // "usr-7" .thenApply(PipelineDemo::fetchEmail) // "usr-7@example.com" .thenApply(PipelineDemo::formatGreeting); // "Welcome, usr-7@example.com!" // Only this line blocks — the pipeline itself is non-blocking System.out.println(greeting.get()); pool.shutdown(); } static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }

thenApplyAsync — offloading the transformation too

thenApply runs its callback on whichever thread completed the previous stage. If that transformation is itself expensive, use thenApplyAsync to hand it off to a pool thread instead.

CompletableFuture<byte[]> pipeline = CompletableFuture .supplyAsync(() -> downloadFile(url), ioPool) .thenApplyAsync(bytes -> compress(bytes), cpuPool); // heavy CPU work — use a dedicated pool
Do not block inside thenApply. Calling Thread.sleep(), JDBC queries, or any other blocking operation inside a thenApply callback ties up a pool thread for the entire wait. Use thenCompose (covered in the next lesson) for steps that themselves return a CompletableFuture, and always give I/O steps their own executor.

Retrieving results: join vs get

Both get() and join() block until the result is ready. The difference is in their exception handling: get() throws checked exceptions (InterruptedException, ExecutionException), while join() wraps everything in an unchecked CompletionException. In lambda chains, join() is more convenient; in application code where you want to handle interruption explicitly, prefer get().

Summary

supplyAsync submits work to a thread pool and returns a live handle to the future result. thenApply chains a pure transformation onto that handle without blocking. Chaining multiple thenApply calls produces a readable, type-safe pipeline where each step is a small, focused function. Always provide a named executor for I/O-bound stages, and never block inside a callback. The next lesson extends these patterns with thenCompose, thenCombine, and error handling.