Java CompletableFuture

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 time

What 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 Future you pull the result out by blocking. With CompletableFuture you 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 runAsync when 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:

  • supplyAsync takes a lambda that returns a value and runs it on a background thread.
  • It returns a CompletableFuture straight away, so the main thread is not blocked.
  • join() fetches the final result. More on join() later.

Output

Task started, main thread is free...
Result: 5050

When 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 finish

Now 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 thenAccept when it takes the value but returns nothing, like printing or saving.
  • Use thenRun when 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:

  • supplyAsync produces the number 10.
  • The first thenApply receives 10 and returns 20.
  • The second thenApply receives 20 and returns a text line.

Output

Final value: 20

This 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 value

Output

Hello, Alex
Done

πŸ”— 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 thenCombine when 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 #42

This 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:

  • price and tax run at the same time, since neither waits for the other.
  • thenCombine takes 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 handle to deal with success and failure in one place. It always runs.
  • handle receives 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 zero
Result: -1

This 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 error

Do 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 DONE

Two 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 end

The 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() or join() 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 away
int 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 exceptionally or handle, a failure stays hidden until join() finally throws it, often far from where it happened.
CompletableFuture.supplyAsync(() -> risky())
.thenApply(x -> x + 1); // ❌ no error handling, failure is invisible
CompletableFuture.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 pool
ExecutorService 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, and thenCombine to link steps, and call join() only once at the end.
  • Pick the right combine method. Use thenCompose when one step needs the previous result, and thenCombine when two independent tasks merge at the end.
  • Always handle errors. Attach exceptionally for a fallback, or handle to 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() over get() 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) or runAsync (no value), and chain with thenApply, thenAccept, and thenRun.
  • βœ… Combine tasks with thenCompose when one depends on another, and thenCombine when two run independently and merge.
  • βœ… Handle failures with exceptionally for a fallback or handle for 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. 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. 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. 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. 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.

Java Lock Interface and ReentrantLock

Share & Connect