Senior 13 min · March 05, 2026

CompletableFuture — get() Inside thenApply() Freezes Pools

8 blocked ForkJoinPool threads froze a payment service.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • CompletableFuture is a composable, non-blocking async pipeline in the Java standard library
  • Use supplyAsync() for async computation, thenApply() to transform results, thenCompose() to flatten nested futures
  • Exception handling with exceptionally() recovers from one failure; handle() always executes; whenComplete() runs regardless
  • ForkJoinPool.commonPool() runs all async tasks by default — it's a shared pool sized by CPU cores, not by IO latency
  • Production killer: blocking calls (e.g., get()) inside async chains steal commonPool threads and cause starvation
  • Silent exceptions are the #1 bug — always install a terminal .exceptionally() or .handle() at every pipeline end
Plain-English First

Imagine you order a pizza, a side salad, and a drink at a restaurant. A bad waiter would order the pizza, stand there staring at the oven, then go get the salad, then get the drink — one at a time. A smart waiter fires all three orders at once and brings everything together when it's all ready. CompletableFuture is that smart waiter for your Java code — it lets you kick off multiple tasks simultaneously, chain work onto them when they finish, and combine their results without blocking a thread to babysit each one.

Every modern backend service has the same dirty secret: most of its time is spent waiting. Waiting on a database query, waiting on an HTTP call to a third-party API, waiting on a cache miss. If you're handling those waits with synchronous blocking calls, you're burning threads — and threads are expensive. At 1 MB of stack space per thread on a typical JVM, blocking 200 threads simultaneously costs you 200 MB just in stack memory alone, before a single byte of business logic runs. This is the hidden scalability ceiling that CompletableFuture was built to shatter.

Before CompletableFuture landed in Java 8, your async options were bleak. You had raw Thread objects (unmanageable at scale), Future (which gave you get() — a blocking call that defeated the whole point), and callback hell via libraries like Guava's ListenableFuture. CompletableFuture changed the game by giving you a composable, non-blocking pipeline model directly in the standard library. You can transform results, chain dependent tasks, combine independent tasks, and handle errors — all without a single blocking get() in your hot path.

By the end of this guide you'll understand exactly how CompletableFuture works under the hood, how to build non-blocking async pipelines that compose and handle errors gracefully, what the ForkJoinPool.commonPool() actually does to your application in production, and the specific mistakes that cause silent failures, thread starvation, and dropped exceptions in real systems.

What is CompletableFuture in Java?

CompletableFuture is a container for an asynchronous computation — it holds a result that may not exist yet. You attach callbacks that fire when the result becomes available, without blocking. That's the fundamental shift from synchronous to reactive programming: instead of pulling a value, you push transformations onto a pipeline that executes when the data arrives. Think of it as a promise that you can chain, combine, and recover from failures. Unlike Java's old Future, you don't have to call get() — you tell the future what to do next, and it does it when ready. That's what makes it 'completable': you can complete it manually (complete(), completeExceptionally()) or let a thread pool complete it for you. In production, this means you can start multiple I/O calls, transform their results in parallel, and combine them – all without blocking a single thread. That's the real win.

What most tutorials skip: CompletableFuture also supports manual completion, which is useful for bridging callback-style APIs (e.g., old REST clients with listeners). You create an incomplete future, pass it to a callback, and call future.complete(result) inside the callback. That pattern turned legacy code into async pipelines without rewriting the I/O layer.

One more trap: when bridging a callback-based API, ensure the callback that calls complete() runs on a dedicated thread pool, not the calling thread. Otherwise you block the callback originator and lose the async benefit. If the callback is invoked on an internal thread, you're fine — but verify.

Don't underestimate how often you'll need manual completion. I've seen entire migrations stalled because the team didn't know about it. It's the escape hatch that makes CompletableFuture work with the real world.

io/thecodeforge/CompletableFutureIntro.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package io.thecodeforge;

import java.util.concurrent.CompletableFuture;

public class CompletableFutureIntro {
    public static void main(String[] args) throws Exception {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            return "Hello from async!";
        });

        // Don't call get() in prod — example only
        System.out.println(future.get()); // Blocks
    }
}
Output
Hello from async!
Why This Matters:
The code above is the simplest async pipeline. In production, you never call get(). Instead, chain with thenApply() or thenAccept().
Production Insight
In production, calling get() on a CompletableFuture inside a web request handler blocks the request thread.
If you also use the common pool for async tasks, a blocked request thread can't process other requests and you risk deadlock.
Rule: never call get() in a web request handler; return the CompletableFuture to the framework (e.g., Spring WebFlux or Servlet 3.1 async) to let it handle async completion.
Key Takeaway
CompletableFuture is a non-blocking container for async results.
Chain transformations via thenApply, thenCompose, thenAccept.
Avoid get() in production — it defeats the purpose and risks thread starvation.

Creating CompletableFuture Instances: runAsync, supplyAsync, and Custom Executors

You can create a CompletableFuture in three main ways: with completedFuture() (for a known value), runAsync() (for Runnable tasks that return nothing), and supplyAsync() (for Supplier tasks that return a result). Both runAsync and supplyAsync by default use ForkJoinPool.commonPool(), a shared pool sized to the number of CPU cores. That's fine for CPU-bound tasks but terrible for I/O — a database call that blocks for 200ms will tie up a core thread for the entire wait.

Always pass a custom Executor for I/O-bound or blocking tasks. Use Executors.newFixedThreadPool() with a size based on your expected concurrency. The rule of thumb: CPU-bound tasks -> fixed pool size = cores + 1. I/O-bound tasks -> experiment, but often 2-4x the number of expected concurrent requests. One more thing: don't share the same executor across multiple pipelines unless they have the same resource profile. A thread blocked on a slow HTTP call can starve a fast cache lookup.

There's a subtle trap: if your custom executor is a fixed thread pool and you block all threads, new tasks are queued. But if the queue is unbounded (like new LinkedBlockingQueue()), you can run into memory pressure because millions of pending tasks pile up. Always use a bounded queue with a rejection policy. Or use a ThreadPoolExecutor with a synchronous queue (direct handoff) and a bounded pool — then tasks that can't run immediately get rejected, which is faster than hiding the problem.

Also remember to monitor your executor's queue depth and active count. Use a metrics library (Micrometer, Dropwizard) to expose these. A deep queue with slow tasks is a warning sign that your pool is undersized or your tasks are blocking. Measure latency distribution of your async tasks — P99 tells you if your pool sizing is correct.

Here's a production pattern: create a separate executor for each downstream dependency. That way, a slow DB doesn't starve your HTTP calls. And if one pool fills up, only that dependency degrades — the rest of your app stays fast.

You don't need a PhD to get this right. Just a metrics dashboard and half an hour of load testing. Experiment with pool sizes, break things, then dial it in.

io/thecodeforge/AsyncCreation.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package io.thecodeforge;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class AsyncCreation {
    // Bounded executor with rejection policy for production
    private static final Executor IO_POOL = new ThreadPoolExecutor(
        20, 20,
        0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<>(100),  // bounded queue
        new ThreadPoolExecutor.CallerRunsPolicy()  // or AbortPolicy
    );

    public static void main(String[] args) {
        // CPU-bound: use common pool
        CompletableFuture.supplyAsync(() -> heavyCpuComputation())
            .thenAccept(System.out::println);

        // I/O-bound: use custom executor
        CompletableFuture.supplyAsync(() -> fetchFromDatabase(), IO_POOL)
            .thenApplyAsync(result -> transform(result), IO_POOL)
            .exceptionally(ex -> {
                System.err.println("DB fetch failed: " + ex);
                return null;
            });
    }

    static String heavyCpuComputation() { return "42"; }
    static String fetchFromDatabase() { return "data"; }
    static String transform(String s) { return s.toUpperCase(); }
}
Output
42
DATA
Common Pool Trap
ForkJoinPool.commonPool() uses a static pool shared across all async tasks in the JVM. Block it in one pipeline and your whole application can stall. Always use a dedicated executor for long-running or blocking tasks.
Production Insight
When you use supplyAsync() without a custom executor, every async task competes for the common pool threads; if you have multiple microservices in the same JVM, they share one pool and can starve each other.
Measure latency distribution of your async tasks — P99 tells you if your pool sizing is correct.
Rule: always pass a dedicated executor to supplyAsync and runAsync, sized for your specific workload.
Key Takeaway
Always pass a custom Executor for I/O tasks.
Common pool is for CPU-bound work only.
Sizing rule: I/O pool = 2-4x max concurrent requests; CPU pool = cores + 1.
Choosing the Right Executor
IfTask is CPU-bound (pure computation, no I/O)
UseUse ForkJoinPool.commonPool() or shared pool sized to cores
IfTask is I/O-bound (network call, file read, DB query)
UseUse a dedicated thread pool with size proportional to expected concurrent calls
IfTask is very short-lived (milliseconds)
UseConsider using the common pool if it won't block
IfTask must not be delayed by other async work
UseIsolate with a separate executor; never rely on common pool for latency-sensitive flows

Chaining and Composing CompletableFutures: thenApply, thenCompose, thenCombine, allOf, anyOf

The real power of CompletableFuture comes from chaining. You can transform the result of a future with thenApply (synchronous transformation), flatten nested futures with thenCompose (like flatMap), or combine two independent futures with thenCombine. For N futures, use allOf (waits for all to complete) and anyOf (waits for the first to complete). These methods are non-blocking and return new CompletableFuture instances.

A common mistake is confusing thenApply with thenCompose. If your transformation function returns a CompletableFuture, use thenCompose — otherwise thenApply. Using thenApply when you should use thenCompose results in a CompletableFuture<CompletableFuture<T>>, which requires unwrapping. In production, that double-wrapping often leads to lost results or unexpected timeouts because the outer future completes immediately while the inner one still runs.

Another subtlety: the default threading behaviour for Async variants (thenApplyAsync, thenComposeAsync) differs. thenApply runs the callback on whatever thread completes the future (often the pool thread). thenApplyAsync always runs the callback on a new task submitted to the executor (or common pool). If you're not careful, thenApplyAsync can thrash the pool by submitting many tiny tasks. Use Async only when you need different parallelism (e.g., offloading to a separate executor) — otherwise stick with the sync variants.

One more thing: when using thenCombine, the two futures run in parallel only if they are created before thenCombine is called. If you create them lazily, they may not run concurrently. Fire both futures eagerly to maximize parallelism. And when using allOf, remember it returns CompletableFuture<Void>. You need to collect each future's result separately. The pattern: list of futures -> allOf -> thenApply that iterates and join() each (safe because all completed).

Don't assume lazily created futures run in parallel — they won't. That's a bug that only shows up under load. I've debugged multiple production outages where someone created futures inside a loop and then tried to combine them, only to find they executed sequentially.

io/thecodeforge/ChainingExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package io.thecodeforge;

import java.util.concurrent.CompletableFuture;

public class ChainingExample {
    public static void main(String[] args) {
        // thenApply: synchronous transformation
        CompletableFuture.supplyAsync(() -> 10)
            .thenApply(x -> x * 2)
            .thenAccept(System.out::println); // 20

        // thenCompose: flatten nested async calls
        CompletableFuture.supplyAsync(() -> "user_id")
            .thenCompose(id -> fetchUserDetails(id))
            .thenAccept(user -> System.out.println("User: " + user));

        // Combine two independent futures
        CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello");
        CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "World");
        future1.thenCombine(future2, (a, b) -> a + " " + b)
            .thenAccept(System.out::println); // Hello World
    }

    static CompletableFuture<String> fetchUserDetails(String id) {
        return CompletableFuture.supplyAsync(() -> "Details for " + id);
    }
}
Output
20
User: Details for user_id
Hello World
Pipeline Thinking
  • Each thenApply is a worker that takes a part and produces a new part.
  • thenCompose is a worker that sends the part to another factory and waits for its output.
  • thenCombine is two conveyor belts merging into one.
  • allOf is a junction that waits for all belts before continuing.
  • *Async variants are like subcontracting to a different factory — use when you need isolation.
Production Insight
Using thenApply when your lambda returns a CompletableFuture creates a double-wrapped future.
The outer future completes immediately, but the inner one must be unwrapped manually, often leading to lost results.
Rule: if your function returns a CompletableFuture, use thenCompose. If it returns a plain value, use thenApply.
Key Takeaway
thenApply for sync transforms, thenCompose for async transforms.
thenCombine for two futures, allOf for N futures.
Never nest futures — use thenCompose to flatten.

Exception Handling in CompletableFuture Pipelines

Futures can complete exceptionally. If you don't handle exceptions, they are silently swallowed. Java provides three main ways to handle exceptions: exceptionally() — used to recover from a specific failure (like a catch block), handle() — always called, receives both result and exception (like try-catch-finally), and whenComplete() — runs after completion (like finally, no recovery).

A common pattern: use exceptionally() as a fallback, handle() for logging and recovery, and whenComplete() for cleanup. Be careful: if the handler itself throws, the new future completes exceptionally with that new exception. In production, always log the original exception before recovering — otherwise you'll never know why the fallback was triggered.

There's a gotcha with exceptionally(): it only catches a single exception type. If your lambda throws a RuntimeException, exceptionally() catches it. But if you want to recover from multiple exception types, you need to chain multiple exceptionally() calls or use handle() with instanceof checks. Java 12+ added exception types in multi-catch pattern matching for lambdas, but that's rarely used.

Also, whenComplete() does NOT allow recovery — it just runs a side effect (like closing resources). If whenComplete() itself throws, that exception propagates to the returned future. Don't use whenComplete() for recovery. If you need both a side effect and recovery, use handle(). Another trap: whenComplete() runs on the thread that completes the future, so if it throws, the exception replaces the original. Never throw from whenComplete().

Here's a real scenario: you have a pipeline that fetches user data, then fetches their orders. If the orders fetch fails with a 503, you want to fall back to an empty list. Use exceptionally() on the orders future to return Collections.emptyList(), then the rest of the pipeline continues. That's graceful degradation.

Really. Never throw from whenComplete(). I've seen a production outage because a whenComplete() threw an exception that masked the real failure. It took hours to find because the original exception was overwritten.

io/thecodeforge/ExceptionHandling.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package io.thecodeforge;

import java.util.concurrent.CompletableFuture;

public class ExceptionHandling {
    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> {
            if (Math.random() > 0.5) throw new RuntimeException("Boom!");
            return "OK";
        })
        .exceptionally(ex -> {
            System.err.println("Recovered from: " + ex.getMessage());
            return "Fallback";
        })
        .thenAccept(System.out::println);

        // handle() always executes, allows recovery or null
        CompletableFuture.supplyAsync(() -> "value")
            .handle((result, ex) -> {
                if (ex != null) {
                    System.err.println("Error: " + ex);
                    return "default";
                }
                return result;
            })
            .thenAccept(System.out::println);
    }
}
Output
Fallback
default (if exception in first, otherwise value)
Silent Failures
Any exception that is not handled in any stage of the pipeline is lost — it won't propagate to the caller until the final get() is called (and if you never call get(), it's gone forever). Always install a terminal exception handler at the end of every pipeline.
Production Insight
If you don't attach an exception handler to a CompletableFuture that completes exceptionally, the exception is 'swallowed' — it exists inside the future but never reaches any log or error handler.
This is the #1 cause of silent data loss in async pipelines: a failed DB write that no one notices.
Rule: always chain a terminal .exceptionally() or .handle() on every pipeline that produces a side effect.
Key Takeaway
Always handle exceptions at the end of every pipeline.
exceptionally() recovers one failure type.
handle() always runs — use it for logging or fallback.

ForkJoinPool.commonPool() and Custom Thread Pools: Production Internals

The ForkJoinPool.commonPool() is a work-stealing pool designed for CPU-bound parallel tasks. Its size equals Runtime.getRuntime().availableProcessors() - 1 (by default, min 1). That's great for parallel computation, disastrous for I/O. When you run 100 IO-bound tasks on a pool of 8 threads, 92 tasks are queued while the 8 threads block on I/O. Your async throughput drops to 8 concurrent operations.

Worse, the common pool is shared across the entire JVM. Spring Boot, Hibernate, Tomcat, and your own code all use it. Blocking it in one place can stall the whole application. Always create a separate thread pool for I/O-bound async work, and consider using a different executor for each type of work (e.g., one for DB calls, one for HTTP calls). Use Executors.newFixedThreadPool() with a meaningful pool size (e.g., max concurrent requests * 2). Don't forget to monitor pool metrics: active threads, queue depth, and rejected tasks. These numbers tell you when you've sized wrong.

Another internal detail: the common pool uses work-stealing, meaning idle threads can steal tasks from busy threads' queues. This is great for CPU-bound tasks where all cores are busy, but not helpful for I/O where threads block and don't produce new tasks to steal. Work-stealing becomes idle overhead. That's another reason to use a dedicated pool for I/O.

Also be aware that the common pool is lazily initialized. If you use it in a static initializer or a class loaded early, you may get a small pool size if you haven't done any parallel work yet. The pool size is computed once at initialization. So if you rely on commonPool in early startup code, verify the pool size explicitly.

Here's a debugging trick: when you suspect common pool starvation, take a thread dump and count how many common pool threads are in WAITING vs RUNNABLE. If all are WAITING, your tasks are blocking. If none are WAITING, you might have too many tasks queued.

So if you're using commonPool for I/O, you're wasting CPU time on context switching that does nothing useful. Don't do it.

io/thecodeforge/PoolInternals.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package io.thecodeforge;

import java.util.concurrent.*;

public class PoolInternals {
    private static final int MAX_CONCURRENT_REQUESTS = 50;
    private static final Executor DB_POOL = 
        Executors.newFixedThreadPool(MAX_CONCURRENT_REQUESTS * 2);

    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> dbQuery(), DB_POOL)
            .thenAcceptAsync(result -> process(result), DB_POOL);
    }

    static String dbQuery() {
        // Blocking JDBC call
        return "result";
    }

    static void process(String s) {}
}
Thread Pool as Finite Parking Lot
  • CommonPool has spaces equal to CPU cores - fine for fast CPU tasks.
  • Blocking I/O tasks park their car and do nothing for 100ms+ - that space is wasted.
  • Allocate separate lots for different types of tasks: one for DB calls, one for HTTP calls.
  • If the lot is full, new requests queue up - response times degrade immediately.
Production Insight
If your async pipeline uses commonPool for a blocking HTTP call, and that HTTP call times out after 5 seconds, those 8 threads are locked for 5 seconds each.
If another request comes in for the same pipeline, it waits in the queue — your response time soars to 5+ seconds.
Rule: never use commonPool for any blocking operation. Create dedicated, bound executors for each resource type.
Key Takeaway
ForkJoinPool.commonPool() is for CPU tasks only.
Create separate thread pools for each I/O resource type.
Sizing: I/O pool = target concurrency × 2 (to account for variability).
Choosing Pool Size for I/O Tasks
IfResponse time target < 200ms, max 500 concurrent requests
UsePool size = 200-400 threads (enough to keep CPU busy while I/O waits)
IfResponse time target < 1s, max 50 concurrent requests
UsePool size = 50-100 threads
IfUnknown load pattern
UseStart with a dynamic pool (newCachedThreadPool) but bound it with a synchronous queue and rejection handler

Timeouts, Cancellation, and Memory Leaks: Production Pitfalls Deep Dive

Beyond thread starvation, CompletableFuture brings several other production traps. Silent exceptions are the worst: if an exception occurs in a stage and you never chain a handler, the future just completes exceptionally and no one notices.

Timeouts are tricky: future.get(timeout, unit) blocks the calling thread. In Java 9+, .orTimeout() is the proper non-blocking way — it creates a timeout watchdog on a separate daemon thread. But beware: .orTimeout() does NOT cancel the underlying task; it just completes the future exceptionally. The still-running task continues to consume resources. If you need cancellation, use .completeOnTimeout() which provides a fallback value, or cancel the future manually after a timeout via a scheduled executor.

Memory leaks: long-lived CompletableFuture chains holding references to large objects can prevent GC. For example, a chain captured in a lambda closure may keep a reference to a heavy response object long after it's needed. This is especially problematic in event-driven or UI applications where futures are held in a map (e.g., for request tracking). Use WeakReferences or clear the chain step references after completion.

Cancellation: future.cancel(true) attempts to interrupt the thread. But the underlying task must be responsive to interruption. Most I/O operations (database queries, HTTP calls) do NOT react to thread.interrupt(). So cancellation may leave connections open and resources leaked. Java 9's .orTimeout() is better because it doesn't rely on interruption.

Memory leaks can also occur if you store CompletableFuture instances in a map keyed by request ID and never remove them. Use a cache with TTL or WeakHashMap for such tracking.

One more: if you use thenApplyAsync with a large lambda that captures a big object, that object stays alive until the lambda runs and finishes. If the lambda is stuck in a queue, the object occupies heap. Use smaller lambdas or pass only necessary data.

WeakReferences are your friend, but they come with a catch — the weak ref can be collected before the future completes. Test this explicitly. I've had a production leak because a WeakReference was GC'd prematurely while the future was still in flight.

io/thecodeforge/TimeoutsAndCancellation.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package io.thecodeforge;

import java.util.concurrent.*;

public class TimeoutsAndCancellation {
    private static final Executor POOL = Executors.newFixedThreadPool(10);

    public static void main(String[] args) throws Exception {
        // Java 9+ non-blocking timeout
        CompletableFuture<String> future = CompletableFuture
            .supplyAsync(() -> {
                try { Thread.sleep(5000); } catch (InterruptedException e) { }
                return "done";
            }, POOL)
            .orTimeout(2, TimeUnit.SECONDS)  // fails with TimeoutException after 2s
            .exceptionally(ex -> {
                System.out.println("Timed out: " + ex.getMessage());
                return "fallback";
            });

        System.out.println(future.get());  // waits up to 2s
        POOL.shutdown();
    }
}
Output
Timed out: java.util.concurrent.TimeoutException
fallback
Silent Exceptions Disappear in Production
An unhandled exception in a CompletableFuture pipeline is like a fire in a closed room — no one sees it until the building burns down. Always install a terminal handler at the end of every pipeline, especially for side-effect stages.
Production Insight
A common memory leak: creating CompletableFuture chains tied to an event loop or UI component that never gets garbage-collected because the future chain holds a strong reference to the component (e.g., a callback that references an Activity in Android).
Rule: always use weak references or remove listeners explicitly in long-lived async pipelines.
Another leak: using Executors.newCachedThreadPool() without a bound — threads can grow unbounded, consuming memory and causing thrashing.
Key Takeaway
Silent exceptions are the #1 async bug — always install handlers.
Use orTimeout() for timeouts, not cancel().
Monitor thread pool metrics: active count, queue depth, rejected tasks.

Real-World Patterns: Parallel API Calls, Aggregation, and Graceful Degradation

The most common production use case for CompletableFuture is making multiple parallel API calls and aggregating their results. The pattern: fire N supplyAsync calls, collect them in a list, call allOf, then .thenApply to extract results. But you must handle per-failure gracefully — you don't want one failed third-party call to fail the entire aggregate.

Solution: wrap each individual future with .exceptionally() to convert failure to a default value (or null). Then after allOf, filter out the nulls. That gives you partial degradation: if one service is down, you still get results from the others. Add a timeout on each individual call so a slow service doesn't hold up the entire aggregation.

Another pattern: first-success wins — use anyOf to kick off multiple sources and take the fastest response (e.g., read from cache and DB simultaneously, whichever returns first). Combine with .applyToEither() for two futures. But beware: when using anyOf, the slower future still runs to completion, burning resources. Use .orTimeout() to abort them after the first completes.

Another pattern: use thenApplyAsync on a separate executor for CPU-intensive post-processing after I/O, to avoid blocking the I/O pool's threads. For example, after fetching data from DB, compress or encode it on a CPU pool.

Here's a real example: you need to load user profile, orders, and recommendations. Fire three futures. If recommendations fails, you still want profile and orders. Wrap recommendations in exceptionally() to return null, then after allOf, handle the case where recommendations is null.

Building resilient aggregates is the difference between a senior and a junior dev. The junior writes allOrNothing. The senior writes bestEffort. Your users don't care that the recommendations service is down — they still want to see their profile.

io/thecodeforge/AggregationPattern.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package io.thecodeforge;

import java.util.concurrent.*;
import java.util.List;
import java.util.stream.Collectors;

public class AggregationPattern {
    private static final Executor HTTP_POOL = Executors.newFixedThreadPool(20);

    public static void main(String[] args) throws Exception {
        List<String> urls = List.of("/api/user", "/api/orders", "/api/inventory");

        // Fire all calls in parallel with timeouts and fallbacks
        List<CompletableFuture<String>> futures = urls.stream()
            .map(url -> CompletableFuture
                .supplyAsync(() -> callHttp(url), HTTP_POOL)
                .orTimeout(2, TimeUnit.SECONDS)
                .exceptionally(ex -> {
                    System.err.println(url + " failed: " + ex);
                    return null;  // fallback
                }))
            .collect(Collectors.toList());

        // Wait for all to complete (even if some failed)
        CompletableFuture<Void> allDone = CompletableFuture.allOf(
            futures.toArray(new CompletableFuture[0]));

        // Extract successful results
        List<String> results = allDone.thenApply(v ->
            futures.stream()
                .map(CompletableFuture::join)  // safe: all already completed
                .filter(r -> r != null)
                .collect(Collectors.toList())
        ).get();

        System.out.println("Aggregated: " + results);
        HTTP_POOL.shutdown();
    }

    static String callHttp(String url) {
        // Simulate HTTP call
        try { Thread.sleep(100); } catch (InterruptedException e) { }
        return url + " response";
    }
}
Output
Aggregated: [/api/user response, /api/orders response, /api/inventory response]
Graceful Degradation
When aggregating multiple sources, never let one failure kill the whole result. Use .exceptionally() per source to provide a default, then filter out failures after allOf.
Production Insight
If you don't add a per-future timeout and fallback, a single slow or failing third-party call blocks the entire aggregation.
Your response time becomes the max of all calls, not a bounded value.
Rule: each parallel call must have its own timeout and fallback.
Key Takeaway
Aggregate with allOf + per-future fallbacks.
Use orTimeout() on each parallel call.
Filter results after collection — don't let one failure ruin the batch.

Testing CompletableFuture Pipelines: Unit Tests, Edge Cases & Production Verification

Testing async code is harder than testing synchronous code — but it doesn't have to be flaky. The key is to avoid real delays in tests. Use CompletableFuture.completedFuture() and completedExceptionally() to create deterministic futures that simulate success and failure without spawning threads. This makes your unit tests fast and reliable.

For integration tests that involve real executors, use CountDownLatch or CompletableFuture.get(timeout, unit) to wait for completion, but always with a short timeout to prevent tests from hanging. A common pitfall: using Thread.sleep() to wait for async work — that's flaky by nature because sleep duration depends on machine load. Instead, poll with a timeout using a loop or use Awaitility library.

Another pattern: when testing a method that returns CompletableFuture, you can call .get() in the test (with a timeout) to obtain the result and then assert. But ensure the method doesn't run on the common pool in tests — use a same-thread executor (Runnable::run) to make tests deterministic.

Mocking is also straightforward: create a mock of the service that returns a CompletableFuture, then use Mockito.when(service.call()).thenReturn(CompletableFuture.completedFuture("value")) or .thenReturn(CompletableFuture.failedFuture(new RuntimeException())). This lets you test exception paths without triggering real failures.

Don't forget to test timeouts. Create a future that never completes by using a CompletableFuture that is never resolved, then call .get(timeout) and assert TimeoutException. Or use .orTimeout() in the code and test that the fallback path executes.

Flaky async tests are worse than no tests — they erode trust in the test suite. Make your tests deterministic by controlling the executor and using completed futures.

io/thecodeforge/AsyncTestExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package io.thecodeforge;

import org.junit.jupiter.api.Test;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;

public class AsyncTestExample {
    @Test
    void testAsyncPipeline() throws Exception {
        // Given: a service that returns a completed future
        CompletableFuture<String> future = CompletableFuture.completedFuture("data");

        // When: we chain a transformation
        CompletableFuture<Integer> result = future.thenApply(String::length);

        // Then: the result is available synchronously
        assertEquals(4, result.get(1, TimeUnit.SECONDS));
    }

    @Test
    void testExceptionHandling() throws Exception {
        CompletableFuture<String> future = CompletableFuture.failedFuture(
            new RuntimeException("API error"));

        CompletableFuture<String> recovered = future
            .exceptionally(ex -> "fallback");

        assertEquals("fallback", recovered.get(1, TimeUnit.SECONDS));
    }
}
Deterministic Testing
Never use Thread.sleep() to wait for async results in tests. It makes tests flaky and slow. Use completedFuture() to simulate results instantly, or use a CountDownLatch with a short timeout.
Production Insight
Flaky async tests in CI cause wasted debug time and lost trust.
If a test fails intermittently, developers start ignoring failures — that's when real bugs slip through.
Rule: use completedFuture() and same-thread executors in unit tests; use real pools only in integration tests with timeouts.
Key Takeaway
Use completedFuture() for deterministic tests.
Avoid Thread.sleep() — it's flaky.
Use same-thread executor to test chaining logic without threads.
● Production incidentPOST-MORTEMseverity: high

The Silent Timeout: How a Blocking Get() Took Down a Payment Service

Symptom
Random timeouts on payment processing, CPU at 100%, thread dumps showing ForkJoinPool threads stuck on Future.get()
Assumption
The team assumed CompletableFuture was 'fire and forget' and didn't review async chains for blocking calls.
Root cause
A developer used future.get() inside a thenApply() to wait for an HTTP response. That blocked the ForkJoinPool thread, which starved other async tasks. Pool size was 8 threads — 8 blocked calls froze all parallelism.
Fix
Replace get() with thenApply()/thenCompose() to chain off the future. If you must wait, use a dedicated thread pool sized for blocking.
Key lesson
  • Never call get() or join() inside any CompletableFuture pipeline. It blocks the commonPool thread.
  • If you must block, run that part in a separate executor (e.g., Executors.newCachedThreadPool()).
  • Always use non-blocking chaining methods (thenApply, thenCompose, thenAccept) instead of waiting.
Production debug guideSymptom → Action guide for async pipeline issues6 entries
Symptom · 01
Some futures never complete (infinite wait)
Fix
Check for unhandled exceptions. Use exceptionally() or handle() at each chaining step. Enable JVM flags -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation for full traces.
Symptom · 02
Application slows down under load, threads stuck
Fix
Take thread dump (jstack pid). Look for ForkJoinPool worker threads in WAITING or BLOCKED state. If blocked on get() or join(), refactor to avoid blocking.
Symptom · 03
Results are missing or incomplete
Fix
Verify allOf/anyOf are properly joined. Use .get(timeout, unit) with a timeout at the final stage to fail fast. Check for swallowed exceptions in completion handlers.
Symptom · 04
CompletableFuture silently swallows exceptions
Fix
Futures that complete exceptionally without a handler suppress the exception. Always install a .exceptionally() or .handle() at the end of every pipeline. Use CompletableFuture.get() only at the boundary.
Symptom · 05
allOf pipeline completes exceptionally even when most futures succeed
Fix
allOf fails fast on first exception. Wrap each component future with .exceptionally() to convert failures to a sentinel value. Then collect results with .handle() post-combination.
Symptom · 06
Memory grows under async load with lambda closures
Fix
Check for captured variables in long-lived chains. Use jmap -histo:live to find retained objects. Break large chains or use weak references in callbacks.
★ CompletableFuture Debug Cheat SheetQuick commands and actions for diagnosing async pipeline issues in production.
Threads stuck on Future.get()
Immediate action
Capture thread dump and look for blocked ForkJoinPool threads.
Commands
jstack <pid> | grep -A 10 'ForkJoinPool'
kill -3 <pid> to get heap dump
Fix now
Replace get() with thenApply() or supplyAsync() with a custom executor.
Silent exception in pipeline+
Immediate action
Add exception handler at each stage.
Commands
future.exceptionally(ex -> { log.error("Async error", ex); return null; });
Check logs for unhandled exceptions. Use -XX:+PrintGCDetails -XX:+PrintConcurrentLocks
Fix now
Wrap entire pipeline in try-catch at final get() or use .handle() to log errors.
Timeout on composed future+
Immediate action
Check if any component future timed out or threw an exception.
Commands
future.get(5, TimeUnit.SECONDS);
Use CompletableFuture.orTimeout(5, SECONDS) (Java 9+)
Fix now
Add .orTimeout() on each long-running async call and handle TimeoutException.
Memory grows under async load+
Immediate action
Check for long-lived future chains holding references to large objects (lambda closures).
Commands
jcmd <pid> GC.class_histogram | head -30
jmap -histo:live <pid> to find retained objects
Fix now
Break large chains, use weak references in callbacks, ensure executors are bounded.
Pipeline never completes, no error in logs+
Immediate action
Take thread dump; check all stages have terminal handlers.
Commands
jstack <pid> | grep -A 20 'CompletableFuture'
Use jcmd <pid> Thread.print
Fix now
Ensure every pipeline ends with .exceptionally() or .handle().
CompletableFuture vs Future vs ListenableFuture
FeatureFuture (Java 5)ListenableFuture (Guava)CompletableFuture (Java 8+)
Blocking get()RequiredOptionalAvoid in pipelines
Async chainingNoneaddCallbackthenApply, thenCompose
Exception handlingCatch on get()Callback failureexceptionally, handle
Manual completionNoNocomplete() / completeExceptionally()
Combining futuresNoFutures.allAsListallOf / thenCombine
Default thread poolNone (manual)MoreExecutorsForkJoinPool.commonPool()
Timeoutsget(timeout)get(timeout)orTimeout() (Java 9+)

Key takeaways

1
CompletableFuture enables non-blocking async pipelines
never call get() inside a chain.
2
Use thenCompose to flatten nested futures; thenApply for plain transformations.
3
Always install a terminal exception handler
exceptionally() or handle() on every pipeline.
4
ForkJoinPool.commonPool() is for CPU tasks only
use dedicated executors for I/O.
5
Monitor thread pool metrics
active count, queue depth, and P99 latency.
6
Test async code deterministically with completedFuture() and same-thread executors.

Common mistakes to avoid

4 patterns
×

Using get() or join() inside an async chain

Symptom
Thread dumps show ForkJoinPool worker threads stuck in WAITING/BLOCKED. App latency spikes under load.
Fix
Replace with thenApply() or thenCompose(). If blocking is unavoidable, offload to a dedicated executor.
×

Confusing thenApply with thenCompose

Symptom
Pipeline returns CompletableFuture<CompletableFuture<T>> — outer future completes instantly, inner future may still run. Results are lost or cause ClassCastException.
Fix
If your lambda returns a CompletableFuture, use thenCompose. For plain values, use thenApply.
×

Forgetting to handle exceptions at the end of a pipeline

Symptom
Operations silently fail. No logs, no errors. The future completes exceptionally but no handler exists to log or recover.
Fix
Chain .exceptionally() or .handle() as the last step in every pipeline. For side-effect pipelines (void), use exceptionally() to log and return a sentinel.
×

Using ForkJoinPool.commonPool() for I/O-bound tasks

Symptom
Under concurrent load, the common pool's threads block on I/O — all parallelism stalls. Other async code in the JVM also freezes.
Fix
Always create a dedicated thread pool for I/O tasks. Size it based on expected concurrency (e.g., max connections * 2). Use Executors.newFixedThreadPool() with a bounded queue.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between thenApply and thenCompose in CompletableF...
Q02SENIOR
How does ForkJoinPool.commonPool() affect CompletableFuture performance?...
Q03SENIOR
Explain the difference between exceptionally(), handle(), and whenComple...
Q01 of 03JUNIOR

What is the difference between thenApply and thenCompose in CompletableFuture?

ANSWER
thenApply is used when the transformation function returns a plain value. thenCompose is used when the function returns another CompletableFuture — it flattens the nested future (flatMap behaviour). Using thenApply when the function returns a CompletableFuture results in CompletableFuture<CompletableFuture<T>>, which requires manual unwrapping and often leads to lost results.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between thenApply and thenApplyAsync?
02
Can CompletableFuture be used for CPU-bound parallel computation?
03
How do I cancel a CompletableFuture properly?
04
What happens to the thread when a CompletableFuture completes exceptionally?
🔥

That's Multithreading. Mark it forged?

13 min read · try the examples if you haven't

Previous
wait notify and notifyAll in Java
8 / 10 · Multithreading
Next
Atomic Classes in Java