CodeGym /Courses /JAVA 25 SELF /thenCompose + custom Executor + timeouts

thenCompose + custom Executor + timeouts

JAVA 25 SELF
Level 55, Lesson 4
Available

1. thenCompose vs. thenApply: the difference and when to use which

In asynchronous programming in Java (via CompletableFuture), you often need to execute chains of actions. There are two similar methods: thenApply and thenCompose. But they behave differently!

thenApply

The thenApply method is used when the next step is a simple value transformation, without starting new asynchronous operations. It takes the result of the previous step, processes it, and returns a new value (not a CompletableFuture).

If you're familiar with the Stream API, then thenApply behaves roughly like map: it takes the result, applies a function, and returns the transformed value.

Example:

CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "42");
CompletableFuture<Integer> lengthFuture = cf.thenApply(s -> s.length());
// lengthFuture contains 2 (the length of the string "42")

Put simply, thenApply is a way to say: “When the result is ready, do this with it.”

thenCompose

  • Used when the next step is another asynchronous operation (returns a CompletableFuture).
  • Allows you to “unwrap” nested CompletableFutures (analogous to flatMap).
  • If you use thenApply with an asynchronous function, you'll get CompletableFuture<CompletableFuture<T>> — inconvenient!

Example:

CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> "user42");

// Suppose we need to get a user's orders by username (asynchronously)
CompletableFuture<List<Order>> ordersFuture = cf.thenCompose(username -> fetchOrdersAsync(username));
// fetchOrdersAsync returns CompletableFuture<List<Order>>

Visually:

  • thenApply: CF<String> → thenApply(s -> s.length()) → CF<Integer>
  • thenCompose: CF<User> → thenCompose(u -> fetchOrdersAsync(u.id)) → CF<List<Order>>

When to use which?

  • The function returns a plain value — use thenApply.
  • The function returns a CompletableFuture — use thenCompose.

Example mistake:

cf.thenApply(username -> fetchOrdersAsync(username)); // You get CF<CF<List<Order>>>
cf.thenCompose(username -> fetchOrdersAsync(username)); // You get CF<List<Order>>

2. Managing the thread pool (Executor): why and how to use your own Executor

Default: ForkJoinPool.commonPool()

When you write CompletableFuture.supplyAsync(...) or thenApplyAsync(...) without specifying an Executor, Java uses the shared thread pool — ForkJoinPool.commonPool(). That's convenient, but not always appropriate:

  • If you have many long or blocking operations (network calls, file I/O), the common pool can get clogged and all tasks will have to wait.
  • Sometimes you need to isolate tasks with different priorities or limit the number of concurrently running threads.

When do you need your own Executor?

  • Long, blocking operations (for example, DB queries, HTTP requests, file reads).
  • Task isolation: so that user-facing tasks don't interfere with system tasks.
  • Resource limiting: for example, do not start more than 10 concurrent downloads.

How to create your own Executor

Typically you use a ThreadPoolExecutor or the factories from Executors:

ExecutorService myExecutor = Executors.newFixedThreadPool(10);

How to use your Executor with CompletableFuture

  • In methods supplyAsync, runAsync, thenApplyAsync, thenComposeAsync, and others, you can pass a second argument — your Executor.

Examples:

CompletableFuture<String> cf = CompletableFuture.supplyAsync(
    () -> loadDataFromNetwork(), myExecutor
);

cf.thenApplyAsync(data -> processData(data), myExecutor)
  .thenAcceptAsync(result -> System.out.println(result), myExecutor);

Important: if you don't specify an Executor, ForkJoinPool.commonPool() will be used.

When is the default Executor enough?

  • For short, CPU-bound tasks (simple computations).
  • When it doesn't matter which thread the task runs on.

3. Handling timeouts: orTimeout and completeOnTimeout

Asynchronous operations can hang or take too long (for example, if a server doesn't respond). To avoid waiting forever, CompletableFuture provides methods for timeouts.

orTimeout

  • Completes the CompletableFuture with a TimeoutException if the operation doesn't finish within the specified time.
  • Does not cancel the task that's actually running, but the downstream chain will receive an error.

Syntax:

cf.orTimeout(3, TimeUnit.SECONDS)
  .exceptionally(ex -> {
      System.out.println("Timeout: " + ex);
      return null;
  });

Example:

CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    Thread.sleep(5000); // simulate a long operation
    return "OK";
});

cf.orTimeout(2, TimeUnit.SECONDS)
  .exceptionally(ex -> {
      System.out.println("Error: " + ex);
      return "TIMEOUT";
  });

Result:

After 2 seconds a TimeoutException will be thrown, and exceptionally will handle the error.

completeOnTimeout

  • Completes the CompletableFuture with the specified value if the operation doesn't finish before the timeout.
  • Doesn't throw an exception; returns a “fallback” value.

Syntax:

cf.completeOnTimeout("DEFAULT", 2, TimeUnit.SECONDS);

Example:

CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    Thread.sleep(5000);
    return "OK";
});

cf.completeOnTimeout("TIMEOUT", 2, TimeUnit.SECONDS)
  .thenAccept(System.out::println); // After 2 seconds it will print "TIMEOUT"

Comparing orTimeout and completeOnTimeout

Method What happens on timeout? How is it handled downstream?
orTimeout
Completes with TimeoutException Can be handled via exceptionally/handle
completeOnTimeout
Completes with the specified value thenAccept/thenApply will receive that value

4. Practice: example with thenCompose, a custom Executor, and a timeout

Task:

  • Fetch a user by id (asynchronously, with a delay).
  • Then asynchronously fetch the user's orders (also with a delay).
  • Use a custom Executor.
  • Add a timeout for fetching orders.
import java.util.concurrent.*;
import java.util.*;

public class AsyncDemo {
    static ExecutorService ioExecutor = Executors.newFixedThreadPool(4);

    // Simulate asynchronous user retrieval
    static CompletableFuture<String> fetchUserAsync(int userId) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(1000);
            return "user" + userId;
        }, ioExecutor);
    }

    // Simulate asynchronous retrieval of user orders
    static CompletableFuture<List<String>> fetchOrdersAsync(String username) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(3000); // Long operation!
            return List.of("order1", "order2");
        }, ioExecutor);
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
    }

    public static void main(String[] args) {
        fetchUserAsync(42)
            .thenCompose(username ->
                fetchOrdersAsync(username)
                    .orTimeout(2, TimeUnit.SECONDS) // Timeout when fetching orders
                    .exceptionally(ex -> {
                        System.out.println("Failed to fetch orders: " + ex);
                        return List.of();
                    })
            )
            .thenAccept(orders -> System.out.println("Orders: " + orders))
            .join(); // Wait for the entire chain to complete

        ioExecutor.shutdown();
    }
}

What happens:

  • Fetch the user (1 second).
  • Fetch the orders (3 seconds, but timeout is 2 seconds).
  • If it doesn't finish in time — we catch a TimeoutException and return an empty list.
  • Everything runs on the custom Executor.

Output:

Failed to fetch orders: java.util.concurrent.TimeoutException
Orders: []

If you reduce the delay in fetchOrdersAsync to 1_000 ms, you'll see the actual orders.

5. Common mistakes and nuances

Mistake No. 1: Using thenApply instead of thenCompose for asynchronous operations.
If a function returns a CompletableFuture and you use thenApply, you'll get the nested type CompletableFuture<CompletableFuture<T>>. This complicates the chain and adds unnecessary wrappers. Solution: use thenCompose to flatten the result into a CompletableFuture<T>.

Mistake No. 2: Running long or I/O tasks without a dedicated Executor.
By default, tasks run in ForkJoinPool.commonPool(). If you overload it, latencies will grow, and other tasks in the application may slow down. Solution: create your own ExecutorService and pass it to supplyAsync/thenApplyAsync.

Mistake No. 3: Expecting orTimeout to cancel the task.
orTimeout only completes the CompletableFuture with a timeout exception; the task itself keeps running in the background. Solution: if you need to stop execution, use cancel(true) or your own interruption mechanisms.

Mistake No. 4: Misunderstanding the scope of a timeout.
orTimeout and completeOnTimeout apply only to a specific step in the chain, not the entire chain. Solution: if you need a global timeout for the whole chain, wrap it in a separate CompletableFuture and apply the timeout to that.

Mistake No. 5: Not shutting down ExecutorService.
If you don't call shutdown()/shutdownNow() on the ExecutorService after tasks finish, threads will keep running, and the program may hang. Solution: always close the ExecutorService in finally or use try-with-resources in Java 21+.

1
Task
JAVA 25 SELF, level 55, lesson 4
Locked
Status Request: Immediate Response with Fallback
Status Request: Immediate Response with Fallback
1
Task
JAVA 25 SELF, level 55, lesson 4
Locked
User Dashboard: Fast Data Access with Smart Management
User Dashboard: Fast Data Access with Smart Management
1
Survey/quiz
Asynchronous programming, level 55, lesson 4
Unavailable
Asynchronous programming
Asynchronous programming
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION