CompletableFuture Basics
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.
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.
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.
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.
Each thenApply call returns a new CompletableFuture. The original is unchanged. This immutable-pipeline style makes each step composable and independently testable.
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.
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.
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.