[THREADING 101] 08 – Future

[

The Problem: Blocking for Results

When you submit a task to a thread pool, you need a way to get the result. Without a placeholder, you’d have to block the calling thread until the work completes:

// Without Future — calling thread is stuck until work finishes
Result result = doExpensiveWork(); // Blocks here for 5 seconds
useResult(result);

Future and CompletableFuture solve this by giving you a handle to a result that will arrive later.

Future (Java 5+)

Future<V> represents the result of an asynchronous computation. You submit a task to an ExecutorService and get a Future back immediately:

ExecutorService executor = Executors.newFixedThreadPool(4);

Future<String> future = executor.submit(() -> {
    Thread.sleep(2000); // Simulate slow work
    return "Result from background task";
});

// Do other work while task runs in background...
System.out.println("Doing other work...");

// When you need the result — blocks until available
String result = future.get(); // Blocks up to 2 seconds
System.out.println(result);

Future API

MethodDescription
get()Blocks until result is available, then returns it
get(long timeout, TimeUnit unit)Blocks with timeout. Throws TimeoutException if expired.
isDone()Returns true if task completed (success, failure, or cancellation)
isCancelled()Returns true if task was cancelled
cancel(boolean mayInterrupt)Attempts to cancel the task

Limitations of Future

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

// Problem 1: get() blocks the calling thread
String result = future.get(); // Thread does nothing while waiting

// Problem 2: No way to chain operations without blocking
String transformed = result.toUpperCase(); // Must block to get result first

// Problem 3: No way to combine multiple futures non-blocking
Future<String> future1 = executor.submit(() -> fetchUser());
Future<String> future2 = executor.submit(() -> fetchOrders());
// How to combine when BOTH are done? Only by blocking on each:
String user = future1.get();    // Block
String orders = future2.get();  // Block
LimitationDescription
Blocking get()Forces you to wait — negates the async benefit
No chainingCan’t say “when done, do this next” without blocking
No combiningCan’t merge results of multiple futures declaratively
No exception handling pipelineErrors must be caught at get() call site
Cannot be manually completedNo way to set a result from outside

CompletableFuture (Java 8+)

CompletableFuture<T> fixes all of these limitations. It supports non-blocking composition, chaining, and pipeline-style error handling:

import java.util.concurrent.CompletableFuture;

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    // Runs on ForkJoinPool.commonPool() by default
    return fetchFromDatabase();
});

// Non-blocking chain — executes WHEN the result arrives
future
    .thenApply(result -> result.toUpperCase())        // Transform
    .thenApply(upper -> "Processed: " + upper)        // Transform again
    .thenAccept(final_ -> System.out.println(final_)) // Consume
    .exceptionally(ex -> {                             // Handle errors
        System.err.println("Failed: " + ex.getMessage());
        return null;
    });

Creating CompletableFutures

// Run async computation that returns a value
CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> "hello");

// Run async computation with no return value
CompletableFuture<Void> cf2 = CompletableFuture.runAsync(() -> doSomething());

// Use custom executor instead of common pool
ExecutorService myPool = Executors.newFixedThreadPool(8);
CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(() -> "hello", myPool);

// Create already-completed futures
CompletableFuture<String> cf4 = CompletableFuture.completedFuture("instant");

// Create and complete manually later
CompletableFuture<String> cf5 = new CompletableFuture<>();
// ... later in another thread:
cf5.complete("done!"); // Any thread can complete it

Transformation Methods (Non-Blocking Chaining)

MethodInput → OutputDescription
thenApply(Function)T → UTransform the result
thenAccept(Consumer)T → voidConsume the result
thenRun(Runnable)void → voidRun action after completion
thenCompose(Function)T → CompletableFuture<U>Flat-map (avoid nested futures)
handle(BiFunction)(T, Throwable) → UTransform result OR error
whenComplete(BiConsumer)(T, Throwable) → voidSide-effect on completion
exceptionally(Function)Throwable → TRecover from error

thenApply vs thenCompose

// thenApply — synchronous transformation
CompletableFuture<String> name = fetchUser()
    .thenApply(user -> user.getName()); // getName() is instant

// thenCompose — async transformation (returns another future)
CompletableFuture<List<Order>> orders = fetchUser()
    .thenCompose(user -> fetchOrdersForUser(user.getId())); // fetchOrders is async

// Without thenCompose, you'd get CompletableFuture<CompletableFuture<List<Order>>>
// thenCompose "flattens" the nested future — similar to flatMap in streams

Combining Multiple Futures

CompletableFuture<String> userFuture = fetchUserAsync(userId);
CompletableFuture<List<Order>> ordersFuture = fetchOrdersAsync(userId);
CompletableFuture<Double> balanceFuture = fetchBalanceAsync(userId);

// Combine two futures
CompletableFuture<String> combined = userFuture.thenCombine(ordersFuture, 
    (user, orders) -> user.getName() + " has " + orders.size() + " orders"
);

// Wait for ALL futures to complete
CompletableFuture<Void> all = CompletableFuture.allOf(
    userFuture, ordersFuture, balanceFuture
);
all.thenRun(() -> {
    // All three are done — safe to call .join() without blocking
    String user = userFuture.join();
    List<Order> orders = ordersFuture.join();
    Double balance = balanceFuture.join();
    // Assemble response
});

// Wait for ANY future to complete (first one wins)
CompletableFuture<Object> fastest = CompletableFuture.anyOf(
    fetchFromServer1(), fetchFromServer2(), fetchFromServer3()
);

Error Handling

CompletableFuture<String> pipeline = fetchData()
    .thenApply(data -> transform(data))
    .thenApply(transformed -> format(transformed))
    .exceptionally(ex -> {
        // Catches any exception from ANY stage above
        logger.error("Pipeline failed", ex);
        return "default value"; // Recovery value
    });

// handle() — process both success and failure
CompletableFuture<String> robust = fetchData()
    .handle((result, error) -> {
        if (error != null) {
            return "fallback";
        }
        return result.toUpperCase();
    });

Async Variants

Every chaining method has an Async variant that runs on a different thread:

// Runs transformation on the SAME thread that completed the previous stage
future.thenApply(x -> transform(x));

// Runs transformation on the common ForkJoinPool
future.thenApplyAsync(x -> transform(x));

// Runs transformation on a custom executor
future.thenApplyAsync(x -> transform(x), myExecutor);

Use async variants when the transformation is CPU-intensive and you don’t want to block the completing thread.

Practical Example: Aggregating API Calls

public CompletableFuture<TripPage> buildTripPage(String tripId) {
    CompletableFuture<Weather> weather = fetchWeatherAsync(tripId);
    CompletableFuture<List<Restaurant>> restaurants = fetchRestaurantsAsync(tripId);
    CompletableFuture<List<Event>> events = fetchEventsAsync(tripId);

    return CompletableFuture.allOf(weather, restaurants, events)
        .thenApply(ignored -> new TripPage(
            weather.join(),
            restaurants.join(),
            events.join()
        ))
        .orTimeout(3, TimeUnit.SECONDS)  // Fail if takes > 3s (Java 9+)
        .exceptionally(ex -> TripPage.fallback());
}

All three API calls run in parallel. When all complete, the results are assembled into a TripPage. If any call takes more than 3 seconds, the whole operation fails with a timeout and falls back to a default page.

Future vs CompletableFuture Summary

FeatureFutureCompletableFuture
Get resultget() — blocksget()/join() — blocks, OR chain non-blocking
Non-blocking chaining✓ (thenApply, thenCompose, etc.)
Combine multiple✓ (allOf, anyOf, thenCombine)
Error handling pipeline✓ (exceptionally, handle)
Manually completable✓ (complete(), completeExceptionally())
Timeout supportOnly via get(timeout)orTimeout(), completeOnTimeout() (Java 9+)
Cancelcancel() — best effortcancel() + completeExceptionally()
IntroducedJava 5Java 8

When to Use Which

ScenarioRecommendation
Simple “fire and forget” + get result laterFuture is sufficient
Chain of transformationsCompletableFuture
Multiple parallel calls + combineCompletableFuture.allOf/anyOf
Callback-based APIsCompletableFuture (manually completable)
Legacy code using ExecutorServiceFuture (natural fit)

Summary

  • Future is a read-only handle that forces you to block on get().
  • CompletableFuture is the modern approach — chainable, combinable, and non-blocking.
  • Use thenApply for sync transforms, thenCompose for async transforms (flat-map).
  • Use allOf to wait for all parallel tasks, anyOf for the first to complete.
  • Error handling flows through the pipeline with exceptionally and handle.
  • With Java 21 virtual threads, the blocking nature of Future.get() is less costly — but CompletableFuture still wins for readable async pipelines.

References

About the author

Maksim

I build AI-powered products and lead engineering teams. I've launched platforms from zero to millions of users and learned most lessons the hard way. I write about the gap between engineering theory and practice, what actually matters when building products, and the decisions that shape teams and systems.

Add Comment

By Maksim

Maksim

Get in touch

Reach out if you want to discuss engineering leadership, collaborate on something interesting, or suggest topics you'd like me to write about.