CompletableFuture runs work on another thread and lets you describe what should happen
when it finishes — without blocking to wait for it. It is how you stop making a caller wait for three
slow things in sequence when they could happen at once.
Starting work
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "result"; // runs on another thread
});
CompletableFuture<Void> sideEffect = CompletableFuture.runAsync(() -> {
System.out.println("logging something"); // no return value
});
System.out.println(future.join()); // blocks until done -> result
supplyAsync when you want a value back, runAsync when you do not. Both
return immediately; the work happens elsewhere.
join() waits for the answer. Note that calling it straight away, as above, gains you
nothing — you have started a thread and then blocked on it. The value comes from doing something else
in between, or from chaining.
Chaining what happens next
CompletableFuture<Integer> pipeline = CompletableFuture
.supplyAsync(() -> "42")
.thenApply(Integer::parseInt) // transform the result
.thenApply(n -> n * 2); // and again
System.out.println(pipeline.join()); // 84
CompletableFuture<Void> consumed = CompletableFuture
.supplyAsync(() -> "done")
.thenAccept(System.out::println) // consume it, return nothing
.thenRun(() -> System.out.println("finished")); // ignore the value entirely
None of those thenX calls blocks. They register what to do when the previous stage
completes, and return a new future representing the stage after that.
thenApply versus thenCompose
The distinction people get wrong. Use thenApply when your function returns a plain
value, and thenCompose when it returns another future:
class Service {
CompletableFuture<String> findUserId(String email) {
return CompletableFuture.supplyAsync(() -> "user-1");
}
CompletableFuture<String> loadProfile(String userId) {
return CompletableFuture.supplyAsync(() -> "profile of " + userId);
}
void run() {
// Wrong shape: a future of a future
CompletableFuture<CompletableFuture<String>> nested =
findUserId("a@b.com").thenApply(this::loadProfile);
// Right: flattened
CompletableFuture<String> flat =
findUserId("a@b.com").thenCompose(this::loadProfile);
System.out.println(flat.join()); // profile of user-1
}
}
If you know streams, this is exactly map versus
flatMap, and for the same reason.
Running several at once
This is where the real gain is. Three calls that each take a second take one second in total, not three:
class Dashboard {
CompletableFuture<String> fetchProfile() { return CompletableFuture.supplyAsync(() -> "profile"); }
CompletableFuture<String> fetchOrders() { return CompletableFuture.supplyAsync(() -> "orders"); }
CompletableFuture<String> fetchOffers() { return CompletableFuture.supplyAsync(() -> "offers"); }
void run() {
CompletableFuture<String> profile = fetchProfile(); // all three start now
CompletableFuture<String> orders = fetchOrders();
CompletableFuture<String> offers = fetchOffers();
CompletableFuture.allOf(profile, orders, offers).join(); // wait for all
System.out.println(profile.join() + " " + orders.join() + " " + offers.join());
}
}
Start every future before joining any of them. Calling fetchProfile().join()
on one line and fetchOrders().join() on the next runs them in sequence and throws away
the entire point.
allOf returns CompletableFuture<Void>, so you collect the individual
results afterwards — by then they are all complete, so join() returns instantly. There
is also anyOf, which completes as soon as the first one does.
To combine exactly two, thenCombine is tidier:
CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> 20);
CompletableFuture<Integer> b = CompletableFuture.supplyAsync(() -> 22);
System.out.println(a.thenCombine(b, Integer::sum).join()); // 42
Exceptions
An exception inside a stage skips every subsequent stage and surfaces at the end, so handle it where you can do something useful:
CompletableFuture<String> recovered = CompletableFuture
.<String>supplyAsync(() -> { throw new IllegalStateException("boom"); })
.exceptionally(e -> "fallback"); // only runs on failure
System.out.println(recovered.join()); // fallback
CompletableFuture<String> both = CompletableFuture
.supplyAsync(() -> "ok")
.handle((value, error) -> error != null ? "failed" : value.toUpperCase());
System.out.println(both.join()); // OK
exceptionally handles only the failure case; handle receives both the
value and the error and runs either way. whenComplete is the third option — it observes
both without changing the result, which is what you want for logging.
Note that join() wraps whatever went wrong in a CompletionException, so
the exception you catch is not the one you threw. Use getCause() to reach it.
Pass your own executor
Every example above uses the default, which is the shared ForkJoinPool.commonPool().
That pool is sized for CPU-bound work and is shared with parallel streams across your whole
application. Blocking it on network calls will starve everything else:
class Service {
void run() {
ExecutorService pool = Executors.newFixedThreadPool(10);
try {
CompletableFuture<String> f =
CompletableFuture.supplyAsync(() -> "io result", pool); // your pool
System.out.println(f.join());
} finally {
pool.shutdown(); // always shut it down
}
}
}
The rule: for anything that waits on I/O, pass your own executor. Leave the common pool for short CPU-bound work.
On Java 21 and later there is a better option for I/O — a virtual thread executor, which creates a cheap thread per task rather than sharing a small pool:
class Service {
void run() {
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
CompletableFuture<String> f = CompletableFuture.supplyAsync(() -> "io result", pool);
System.out.println(f.join());
}
}
}
Virtual threads are cheap enough that blocking one costs almost nothing, which removes most of the reason to build elaborate async pipelines in the first place.
Timeouts, and the one you must not forget
A future that never completes will keep a caller waiting forever. Since Java 9 there is a built-in answer:
CompletableFuture<String> slow = CompletableFuture.supplyAsync(() -> "eventually");
System.out.println(slow.completeOnTimeout("default", 2, TimeUnit.SECONDS).join());
CompletableFuture<String> strict = CompletableFuture
.supplyAsync(() -> "eventually")
.orTimeout(2, TimeUnit.SECONDS) // fails with TimeoutException instead
.exceptionally(e -> "gave up");
System.out.println(strict.join());
completeOnTimeout substitutes a fallback value; orTimeout fails the
future so an exceptionally stage can decide. Either is better than the third option,
which is waiting indefinitely.
The three things that go wrong
- Blocking immediately.
supplyAsync(...).join()on one line is a thread handoff and a wait, which is slower than just calling the method. The gain comes from starting several, or from returning the future to a caller who will use it later. - Sequential joins. Joining each future before starting the next serialises work you meant to parallelise. Start them all, then join.
- Swallowed failures. A future whose result nobody joins and which has no
exceptionallyorwhenCompletefails silently — no stack trace anywhere. Always terminate a chain with something that observes the outcome.
Next
That closes the Java 8 shift. The next section walks each LTS release since, starting with the Java 11 String methods.