Java CompletableFuture
Table of Contents + β
In the last lesson you learned about Java Callable and Future. A Future gives you a claim ticket for a result, but reading it means calling get(), which stops your thread and waits. Javaβs better tool is CompletableFuture, which lets you build a whole pipeline of async steps that run on their own, without ever sitting and waiting.
π€ The problem with plain Future
Picture a chain of work. Fetch a user. Use the user to fetch their orders. Turn the orders into a report. Each step needs the one before it.
With a plain Future, the only way to move between steps is get(). And get() blocks. So the thread freezes at every step.
This code blocks three separate times just to do one chain of work.
Future<User> userF = pool.submit(() -> fetchUser());User user = userF.get(); // β blocks here
Future<List<Order>> ordersF = pool.submit(() -> fetchOrders(user));List<Order> orders = ordersF.get(); // β blocks again
Future<String> reportF = pool.submit(() -> buildReport(orders));String report = reportF.get(); // β blocks a third timeWhat is wrong here:
- Every
get()freezes the thread until that step finishes. - A step cannot start until the previous
get()returns. - Lots of plumbing just to glue three steps together.
You want to describe the pipeline once and let it run on its own. That is what CompletableFuture gives you.
π§© What CompletableFuture is
A CompletableFuture is a future that knows how to chain. Think of it like a relay race. Each runner does their lap, then hands the baton to the next. Nobody waits for the whole race to finish.
- It still holds a result that is not ready yet.
- Instead of making you wait, you attach follow-up actions.
- Each action runs the moment the value before it is ready.
- With
Futureyou pull the result out by blocking. WithCompletableFutureyou push it forward.
π οΈ Creating one with supplyAsync and runAsync
Two ways to start an async task, and the choice is one question: does your task return a value?
- Use supplyAsync when the task produces a result.
- Use
runAsyncwhen the task just does work and returns nothing. - Both run on a background thread by default.
This program starts a task that computes a number and gives it back.
import java.util.concurrent.CompletableFuture;
public class Main { public static void main(String[] args) throws Exception { CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> { int sum = 0; for (int i = 1; i <= 100; i++) sum += i; return sum; // β
supplyAsync returns a value });
System.out.println("Task started, main thread is free..."); System.out.println("Result: " + future.join()); // collect when ready }}What each part does:
supplyAsynctakes a lambda that returns a value and runs it on a background thread.- It returns a
CompletableFuturestraight away, so the main thread is not blocked. join()fetches the final result. More onjoin()later.
Output
Task started, main thread is free...Result: 5050When your task has no result to return, use runAsync instead. This task just prints a message on a background thread.
import java.util.concurrent.CompletableFuture;
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { System.out.println("Saving file in the background..."); // β
no return value});
future.join(); // wait for it to finishNow the real power begins: chaining steps onto the result.
π Chaining with thenApply, thenAccept, thenRun
Once you have a value coming, you attach the next step. Three methods, picked by what the next step needs and gives back.
- Use thenApply when the next step takes the value and returns a new value.
- Use
thenAcceptwhen it takes the value but returns nothing, like printing or saving. - Use
thenRunwhen it ignores the value, like logging that you are done.
This pipeline computes a number, then turns it into a sentence, all without blocking in the middle.
import java.util.concurrent.CompletableFuture;
public class Main { public static void main(String[] args) { CompletableFuture<String> future = CompletableFuture .supplyAsync(() -> 5 + 5) // produces 10 .thenApply(sum -> sum * 2) // β
takes 10, returns 20 .thenApply(doubled -> "Final value: " + doubled); // β
returns a String
System.out.println(future.join()); }}How the baton moves down the chain:
supplyAsyncproduces the number 10.- The first
thenApplyreceives 10 and returns 20. - The second
thenApplyreceives 20 and returns a text line.
Output
Final value: 20This chain transforms a value, then prints it with thenAccept, then runs a closing message with thenRun.
CompletableFuture.supplyAsync(() -> "Alex") .thenApply(name -> "Hello, " + name) // returns a new String .thenAccept(greeting -> System.out.println(greeting)) // β
uses value, returns nothing .thenRun(() -> System.out.println("Done")); // β
ignores valueOutput
Hello, AlexDoneπ Combining results: thenCompose and thenCombine
Real work often needs two async tasks together. The difference is whether one task depends on the other.
- Use thenCompose when the second task needs the result of the first.
- Use
thenCombinewhen the two tasks are independent and you only merge their answers at the end.
This pipeline fetches a user id, then uses it to fetch that userβs name, in a clean chain.
import java.util.concurrent.CompletableFuture;
public class Main { public static void main(String[] args) { CompletableFuture<String> result = CompletableFuture .supplyAsync(() -> 42) // step 1: get an id .thenCompose(id -> CompletableFuture // β
step 2 depends on the id .supplyAsync(() -> "User #" + id));
System.out.println(result.join()); }}Why thenCompose and not thenApply? Step two itself returns a CompletableFuture. With thenApply you would get a future inside a future. thenCompose flattens that into one clean future.
Output
User #42This program fetches a price and a tax rate in parallel with thenCombine, then combines them into a total.
import java.util.concurrent.CompletableFuture;
public class Main { public static void main(String[] args) { CompletableFuture<Double> price = CompletableFuture.supplyAsync(() -> 100.0); CompletableFuture<Double> tax = CompletableFuture.supplyAsync(() -> 0.18);
CompletableFuture<Double> total = price.thenCombine(tax, (p, t) -> p + (p * t)); // β
runs once BOTH are ready
System.out.println("Total: " + total.join()); }}The key detail:
priceandtaxrun at the same time, since neither waits for the other.thenCombinetakes the other future plus a function of both results.- Its function runs only after both values are ready, then returns the merged answer.
Output
Total: 118.0β οΈ Handling errors with exceptionally and handle
Async work fails too. You do not wrap everything in try-catch. You attach a recovery step to the pipeline.
- Use exceptionally for a fallback value when a step throws. It runs only on failure.
- Use
handleto deal with success and failure in one place. It always runs. handlereceives the result (null if it failed) and the exception (null if it succeeded).
This task divides by zero, and exceptionally catches the failure and supplies a safe default.
import java.util.concurrent.CompletableFuture;
public class Main { public static void main(String[] args) { CompletableFuture<Integer> future = CompletableFuture .supplyAsync(() -> 10 / 0) // β throws inside the task .exceptionally(ex -> { // β
runs only on failure System.out.println("Failed: " + ex.getMessage()); return -1; // fallback value });
System.out.println("Result: " + future.join()); }}Output
Failed: java.lang.ArithmeticException: / by zeroResult: -1This handle step inspects both arguments and decides what to return.
CompletableFuture<String> future = CompletableFuture .supplyAsync(() -> 10 / 0) .handle((result, ex) -> { // β
runs on success AND failure if (ex != null) return "Recovered from error"; return "Got " + result; });
System.out.println(future.join());Output
Recovered from errorDo not swallow the exception silently
It is tempting to write an empty exceptionally that just returns a default and ignores the error. Then a real bug hides forever. Always at least log ex so you can see what failed. A silent failure is worse than a loud one.
π₯ Getting the result with join and get
At the end of a pipeline you want the final value. Two ways to fetch it, and they differ in how they report errors.
- Use join for most cases. It throws unchecked, so no try-catch is needed. Cleaner inside lambdas and streams.
get()does the same waiting but throws checked exceptions, so you must catch or declare them.- Both block. So call them only at the very end, once the whole pipeline is described.
This snippet shows both, side by side.
String a = future.join(); // β
throws unchecked, no try-catch needed
try { String b = future.get(); // must handle checked exceptions} catch (Exception e) { e.printStackTrace();}βοΈ Running on a custom executor
By default, supplyAsync and friends run on a shared pool called the common ForkJoinPool.
- The shared pool is fine for quick CPU work.
- It is a poor fit for tasks that wait a lot, like network or file calls.
- Those waiting tasks can hog the shared pool and starve everything else.
- So pass your own executor. Every async method has a version that takes an extra executor argument.
This program creates a pool, runs the task on it, then shuts the pool down.
import java.util.concurrent.CompletableFuture;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;
public class Main { public static void main(String[] args) { ExecutorService pool = Executors.newFixedThreadPool(4);
CompletableFuture<String> future = CompletableFuture .supplyAsync(() -> "work done", pool) // β
runs on YOUR pool .thenApply(s -> s.toUpperCase());
System.out.println(future.join());
pool.shutdown(); // β
always shut your own pool down }}Output
WORK DONETwo habits to keep:
- Give blocking tasks their own executor, so they never starve the shared pool.
- Shut your pool down when you are finished.
βοΈ Why it beats blocking future.get()
Back to the chain we struggled with at the start. Now it is the same user-orders-report pipeline, non-blocking from top to bottom.
CompletableFuture .supplyAsync(() -> fetchUser()) // step 1 .thenCompose(user -> CompletableFuture .supplyAsync(() -> fetchOrders(user))) // step 2 uses step 1 .thenApply(orders -> buildReport(orders)) // step 3 uses step 2 .thenAccept(report -> System.out.println(report)) // final action .join(); // β
block ONCE, only at the very endThe win:
- You block once, at the end, instead of after every step.
- Each step starts automatically when the one before it is ready.
- The pipeline reads top to bottom like a recipe, not a pile of
get()calls.
β οΈ Common Mistakes
A few CompletableFuture slip-ups catch almost everyone at first.
- Blocking with
get()orjoin()too early. Calling it in the middle of your chain throws away the whole point of being non-blocking.
int value = CompletableFuture.supplyAsync(() -> compute()).join(); // β blocks right awayint result = CompletableFuture.supplyAsync(() -> process(value)).join();CompletableFuture.supplyAsync(() -> compute()) .thenApply(value -> process(value)) // β
chain instead of blocking between steps .thenAccept(System.out::println) .join(); // β
block once, at the end- Swallowing exceptions. If you never attach
exceptionallyorhandle, a failure stays hidden untiljoin()finally throws it, often far from where it happened.
CompletableFuture.supplyAsync(() -> risky()) .thenApply(x -> x + 1); // β no error handling, failure is invisibleCompletableFuture.supplyAsync(() -> risky()) .thenApply(x -> x + 1) .exceptionally(ex -> { // β
catch and log System.out.println("Failed: " + ex.getMessage()); return 0; });- Not providing an executor for blocking tasks. Running a slow network call on the default shared pool can starve every other task using it.
CompletableFuture.supplyAsync(() -> callSlowNetwork()); // β on the shared common poolExecutorService pool = Executors.newFixedThreadPool(8);CompletableFuture.supplyAsync(() -> callSlowNetwork(), pool); // β
on your own poolβ Best Practices
Habits that keep your async code clean and fast.
- Chain, do not block in the middle. Use
thenApply,thenCompose, andthenCombineto link steps, and calljoin()only once at the end. - Pick the right combine method. Use
thenComposewhen one step needs the previous result, andthenCombinewhen two independent tasks merge at the end. - Always handle errors. Attach
exceptionallyfor a fallback, orhandleto deal with success and failure in one place. Never let a failure pass silently. - Use your own executor for blocking work. Keep network and file tasks off the shared common pool, and shut your pool down when done.
- Prefer
join()overget()in pipelines. It throws unchecked, so it reads cleaner inside lambdas and streams.
π§© What Youβve Learned
Nice work. Letβs recap CompletableFuture.
- β CompletableFuture is the modern, non-blocking future that lets you chain async steps instead of waiting on each one.
- β
Start with
supplyAsync(returns a value) orrunAsync(no value), and chain withthenApply,thenAccept, andthenRun. - β
Combine tasks with
thenComposewhen one depends on another, andthenCombinewhen two run independently and merge. - β
Handle failures with
exceptionallyfor a fallback orhandlefor both outcomes, and pass a custom executor for blocking work. - β
Call
join()once at the very end, so the whole pipeline runs without blocking between steps.
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Which method starts an async task that returns a value?
Why: supplyAsync runs a task that supplies a value; runAsync is for tasks that return nothing.
- 2
When should you use thenCompose instead of thenCombine?
Why: thenCompose chains a dependent step whose task itself returns a CompletableFuture; thenCombine merges two independent results.
- 3
What does exceptionally do in a pipeline?
Why: exceptionally runs only on failure and returns a replacement value so the pipeline can continue.
- 4
Why does CompletableFuture beat blocking future.get()?
Why: You describe the chain once and block only at the end, instead of calling get() after every step.
π Whatβs Next?
You can now build non-blocking async pipelines. Next we look at lower-level locking, where you control exactly which thread holds access to shared data and for how long.