Java Concurrency

Future vs CompletableFuture

Future represents a result that may be available later. CompletableFuture adds async composition: chaining, combining, transforming, and handling errors without immediately blocking.

ConcurrencyFutureCompletableFutureAsyncJava

The Short Answer

A Future represents a result that may be available later.

A CompletableFuture also represents a result that may be available later, but it lets you chain, combine, transform, and handle async results much more naturally.

Future usually makes you ask: “Is the result ready yet?”
CompletableFuture lets you say: “When the result is ready, do the next thing.”

The Real Problem

Imagine a backend service calling another service in the background. You submit the task and get back a Future.

That sounds good, but the awkward part is what happens next. With a plain Future, you usually call get(), and that blocks the current thread until the result is ready.

Future

Submit task
Get Future
Call get() and block

CompletableFuture

Start async task
Attach next step
Continue when result is ready

Future Example

Future is commonly returned by ExecutorService when you submit a Callable.

java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class FutureExample {
    public static void main(String[] args) throws Exception {
        ExecutorService executor =
                Executors.newFixedThreadPool(2);

        Future<String> future =
                executor.submit(() -> {
                    Thread.sleep(1_000);
                    return "payment approved";
                });

        System.out.println("Doing other work...");

        String result = future.get(); // blocks here

        System.out.println(result);

        executor.shutdown();
    }
}

This works, but the blocking call is the limitation. Once you call get(), the current thread waits.

Future Mental Model

A Future is like a receipt for work happening somewhere else.

But when you want the actual result, you usually have to stand at the counter and wait.

Future gives you basic methods:

  • isDone()
  • isCancelled()
  • cancel()
  • get()
  • get(timeout, unit)

Useful, but limited.

CompletableFuture Example

CompletableFuture lets you attach behavior that runs when the result is ready.

java
import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) {
        CompletableFuture<String> future =
                CompletableFuture.supplyAsync(() -> {
                    sleep(1_000);
                    return "payment approved";
                });

        CompletableFuture<String> message =
                future.thenApply(result ->
                        "Result: " + result
                );

        System.out.println(message.join());
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
        }
    }
}

The key difference is that thenApply() describes the next transformation instead of forcing you to block immediately.

thenApply vs thenCompose

These two methods are easy to confuse.

thenApply

Use when you have a normal synchronous transformation from one value to another.

thenCompose

Use when the next step itself returns another CompletableFuture.

java
CompletableFuture<String> userFuture =
        CompletableFuture.supplyAsync(() -> "user-123");

CompletableFuture<String> displayName =
        userFuture.thenApply(userId ->
                "Display name for " + userId
        );
java
CompletableFuture<String> userFuture =
        CompletableFuture.supplyAsync(() -> "user-123");

CompletableFuture<String> profileFuture =
        userFuture.thenCompose(userId ->
                fetchProfileAsync(userId)
        );

static CompletableFuture<String> fetchProfileAsync(String userId) {
    return CompletableFuture.supplyAsync(() ->
            "Profile for " + userId
    );
}
thenApply transforms a value.
thenCompose chains another async operation.

Combining Two Async Results

CompletableFuture is especially useful when two async tasks can run in parallel and then be combined.

java
import java.util.concurrent.CompletableFuture;

public class CombineFuturesExample {
    public static void main(String[] args) {
        CompletableFuture<Integer> priceFuture =
                CompletableFuture.supplyAsync(() -> 100);

        CompletableFuture<Integer> taxFuture =
                CompletableFuture.supplyAsync(() -> 8);

        CompletableFuture<Integer> totalFuture =
                priceFuture.thenCombine(
                        taxFuture,
                        (price, tax) -> price + tax
                );

        System.out.println(totalFuture.join()); // 108
    }
}

This is much cleaner than manually blocking on two Future objects and then combining the results yourself.

Handling Errors

CompletableFuture also gives you a more fluent way to handle failures.

java
import java.util.concurrent.CompletableFuture;

public class CompletableFutureErrorExample {
    public static void main(String[] args) {
        CompletableFuture<String> future =
                CompletableFuture.supplyAsync(() -> {
                    throw new RuntimeException("payment service failed");
                });

        String result = future
                .exceptionally(ex -> "fallback response")
                .join();

        System.out.println(result);
    }
}

Instead of catching the exception only around a blocking get(), you can attach error handling to the async pipeline.

Why CompletableFuture Is A Bigger Leap Than It First Appears

When developers first learn CompletableFuture, it often feels like a small improvement over Future.

java
Future<String> future =
        executor.submit(...);

String result = future.get();
java
CompletableFuture<String> future =
        CompletableFuture.supplyAsync(...);

String result = future.join();

At first glance these look very similar. In both cases, you eventually wait for the result. Yes, CompletableFuture allows composition of multiple tasks, but then you have to wait at the end anyway, right?

The key insight is that CompletableFuture often lets you avoid waiting immediately.

Future encourages you to ask:
"Is the result ready yet?"

CompletableFuture encourages you to say:
"When the result is ready, do this next."

Instead of blocking right away, you can describe an entire pipeline of work:

Fetch User
    ↓
Fetch Orders
    ↓
Combine Results
    ↓
Build Dashboard
    ↓
Return Response

This becomes especially powerful when multiple independent operations can run in parallel and then be combined later.

That is why CompletableFuture is often described as an async workflow or async composition framework rather than simply a better Future.

CompletableFuture Can Represent Work That Hasn't Happened Yet

Another capability that is not obvious at first is that a CompletableFuture does not always have to be tied directly to a running thread.

You can create an empty CompletableFuture and complete it later when some external event occurs.

java
CompletableFuture<String> future =
        new CompletableFuture<>();

// The computation is finished.
// The result is "done".
future.complete("done");

System.out.println(future.join()); // done

The value passed to complete() becomes the result that anyone waiting on the CompletableFuture receives.

A regular Future cannot do this.

A Future usually represents work that is already running somewhere.

A CompletableFuture can represent work, an event, or a result that may arrive sometime in the future.

Things become more interesting when callbacks are attached before the result is available.

java
CompletableFuture<String> future =
        new CompletableFuture<>();

future.thenAccept(value ->
        System.out.println(
                "Received: " + value
        )
);

// Some external event occurs later...
future.complete("payment approved");
text
Received: payment approved

Here, thenAccept() registers code that should run when the result becomes available. Nothing happens immediately. When complete() is eventually called, the callback executes and receives the supplied value.

This turns out to be useful when integrating with systems that are not simple thread-based tasks.

Message Queues

Complete the future when a Kafka message or queue response arrives.

Callbacks

Complete the future when an external callback is triggered.

Event Systems

Complete the future when an application event occurs.

WebSockets

Complete the future when a message arrives from a client or server.

Most Spring Boot applications only use a small subset of CompletableFuture's capabilities. However, this ability to represent results that have not happened yet is one of the reasons the class is much more powerful than Future.

The mental model:

Future = "I started some work. Tell me when it finishes."

CompletableFuture = "Something will eventually produce a result. In the meantime, let me describe what should happen next."

Future vs CompletableFuture Comparison

FeatureFutureCompletableFuture
Represents async resultYesYes
Usually blocks to get resultYesNot necessarily
Chain transformationsNoYes
Combine async resultsManualBuilt-in
Error handling pipelineLimitedBetter
Can be manually completedNoYes

join() vs get()

Both can retrieve the final value, but they handle exceptions differently.

get()

Throws checked exceptions: InterruptedException and ExecutionException.

join()

Throws unchecked CompletionException if the computation failed.

You will often see join() in examples because it is less noisy for demo code.

Common CompletableFuture Methods

supplyAsync

Run async work that returns a value.

runAsync

Run async work that does not return a value.

thenApply

Transform a result.

thenCompose

Chain another async operation.

thenCombine

Combine two independent async results.

exceptionally

Recover from a failure with a fallback value.

When Would You Use Which?

Use Future When

You only need to submit a task and later block to retrieve the result.

Use CompletableFuture When

You need async chaining, combining, transformation, fallback, or non-blocking style workflows.

Interview-Friendly Explanation

Future is the older abstraction for a result that will be available later. It is useful, but limited because you normally block with get(). CompletableFuture is more powerful because it supports completion, chaining, combining, transformations, and async error handling. It lets you build a pipeline of what should happen when the async result is ready.

Common Interview Follow-Ups

Is CompletableFuture a Future?

Yes. CompletableFuture implements Future and CompletionStage.

Why is Future limited?

Future can represent a pending result, but it does not naturally support chaining, combining, or callback-style transformations. You usually call get(), which blocks.

What is the difference between thenApply and thenCompose?

thenApply is for transforming a value. thenCompose is for chaining another async operation that returns a CompletableFuture.

What is the difference between join and get?

get throws checked exceptions. join throws unchecked CompletionException when the computation fails.

Does CompletableFuture always create a new thread?

No. Async methods run using an Executor. If you do not provide one, common async methods typically use the common ForkJoinPool.

Final Takeaway

Future is a handle to a result that may be ready later. CompletableFuture is a full async composition tool. If you only need to submit and block, Future may be enough. If you need to chain, combine, transform, or recover from async work, CompletableFuture is usually the better tool.