CompletableFuture<T> is the JDK's tool for composing asynchronous work. A plain Future only offers get(), which blocks, and no way to say "when this finishes, do that". CompletableFuture implements both Future and CompletionStage: you describe a dependency graph of stages, and each stage runs when its inputs complete.

Used well, it fans out remote calls in parallel, applies timeouts and fallbacks per call, and joins the results without parking threads. Used carelessly, it hides exceptions, runs callbacks on threads you did not expect, and starves the shared pool. This article explains the mechanism first, then the API rules that follow from it, a worked service-aggregation example, failure modes, and when virtual threads make it unnecessary.

Inside a CompletableFuture: one result cell and a stack of dependentsProducersupplyAsync / complete()CompletableFutureresult: empty | value | exceptionCAS resultDependents stackTreiber stack of Completionspush / popthenApply(f)runs on completing threadthenApplyAsync(f, ex)submitted to executor exthenCompose(g)waits for g's inner futureException pathCompletionException(cause)exceptionally / handlerecover to a valuewhenCompleteobserve only, passes throughEdge of the program: join() / get() / orTimeout()block only at the boundary; everything inside the pipeline stays non-blocking
A CompletableFuture is a result cell and a stack of dependents. Completion fires the dependents on the completing thread unless an async variant hands them to an executor; exceptions travel down until a handler recovers.

How it works: a result cell and a stack of dependents

A CompletableFuture is a single result cell plus a stack of dependent actions. The cell is empty, holds a value, or holds an exception (null results are stored with a sentinel). Completion is a compare-and-set on that cell, so exactly one of many racing complete or completeExceptionally calls wins; the others return false.

Calling thenApply(f) does one of two things. If the future is already complete, the call runs f immediately on the calling thread. If not, it pushes a small completion object onto the stack and returns a new, empty future. When the source completes, the completing thread pops and fires the dependents. That one fact explains the thread rules below: a non-async callback runs on whichever thread happens to finish the previous stage, or on yours.

Every stage returns a new future. Pipelines are trees, not mutable chains, so attaching two callbacks to the same future runs both, and a handler only covers the stages above it in its own branch.

How it works: a result cell and a stack of dependents

A CompletableFuture is a single result cell plus a stack of dependent actions. The cell is empty, holds a value, or holds an exception (null results are stored with a sentinel). Completion is a compare-and-set on that cell, so exactly one of many racing complete or completeExceptionally calls wins; the others return false.

Calling thenApply(f) does one of two things. If the future is already complete, the call runs f immediately on the calling thread. If not, it pushes a small completion object onto the stack and returns a new, empty future. When the source completes, the completing thread pops and fires the dependents. That one fact explains the thread rules below: a non-async callback runs on whichever thread happens to finish the previous stage, or on yours.

Every stage returns a new future. Pipelines are trees, not mutable chains, so attaching two callbacks to the same future runs both, and a handler only covers the stages above it in its own branch.

Creating and completing futures

// async work on an executor (common pool if omitted)
CompletableFuture<User> user = CompletableFuture.supplyAsync(() -> users.load(id), ioPool);
CompletableFuture<Void> audit = CompletableFuture.runAsync(() -> auditLog.write(id), ioPool);

// already-complete futures, useful in tests and fallbacks
CompletableFuture<Price> cached = CompletableFuture.completedFuture(price);
CompletableFuture<Price> failed = CompletableFuture.failedFuture(new IllegalStateException("no price"));

// bridge a callback API: complete it by hand
CompletableFuture<Response> bridge = new CompletableFuture<>();
client.send(request, new Callback() {
    public void onSuccess(Response r) { bridge.complete(r); }
    public void onError(Throwable t)  { bridge.completeExceptionally(t); }
});

Methods without an executor argument use ForkJoinPool.commonPool(), which is sized to the number of processors minus one and shared with parallel streams and other libraries. If its parallelism is below two, CompletableFuture creates a new thread per task instead. Blocking calls such as JDBC or HTTP inside the common pool occupy those few threads and stall unrelated code, so pass your own executor to every *Async method that does I/O.

Return CompletionStage, or the read-only view from minimalCompletionStage(), from public APIs when you do not want callers to complete or cancel your future.

Composition: map, flatMap and joining

MethodFunction shapeUse it when
thenApply(f)T -> Utransforming a result (map)
thenCompose(f)T -> CompletionStage<U>the next step is itself asynchronous (flatMap)
thenAccept(f) / thenRun(r)T -> void / () -> voida terminal side effect
thenCombine(other, f)(T, U) -> Vjoining two independent futures
allOf(fs...)none; returns CompletableFuture<Void>waiting for many
anyOf(fs...)none; returns CompletableFuture<Object>first to finish wins, untyped

Using thenApply with a function that returns a future gives CompletableFuture<CompletableFuture<U>>, the same map-versus-flatMap mistake as with Optional. If the next step returns a future, use thenCompose.

allOf carries no results, so collect them after it completes; by then every join() returns immediately:

static <T> CompletableFuture<List<T>> sequence(List<CompletableFuture<T>> fs) {
    return CompletableFuture.allOf(fs.toArray(CompletableFuture[]::new))
        .thenApply(v -> fs.stream().map(CompletableFuture::join).toList());
}

Which thread runs your callback

The rule follows from the mechanism. thenApply, thenAccept and the other non-async methods run the callback on the thread that completed the previous stage, or on the caller if it was already complete. The Async variants always submit the callback to an executor: yours if you pass one, otherwise the common pool.

cf.thenApply(this::parse)                  // completing thread, or the caller
  .thenApplyAsync(this::enrich, cpuPool)   // always cpuPool
  .thenAcceptAsync(this::store, ioPool);   // always ioPool

Two consequences matter in practice. First, a non-async callback attached to a future completed by an HTTP client's I/O thread or a Netty event loop runs on that thread; anything slow there stalls every other connection it serves. Hop off with an async variant. Second, ThreadLocal context such as logging MDC, security context or tracing spans does not follow the callback to another thread. Capture what you need before submitting, or wrap the executor so it copies context on submit. The ExecutorService guide covers sizing those pools.

Exceptions that actually propagate

An exception in any stage completes that stage exceptionally, and every dependent completes exceptionally too, skipping its function, until a handler recovers. Dependents see the error wrapped in CompletionException; a handler attached directly to the future that was completed with completeExceptionally(ex) sees ex itself. Unwrap before branching on type:

static Throwable unwrap(Throwable t) {
    while ((t instanceof CompletionException || t instanceof ExecutionException) && t.getCause() != null)
        t = t.getCause();
    return t;
}

CompletableFuture<Price> price = pricing.quote(sku)
    .exceptionally(ex -> {
        if (unwrap(ex) instanceof NotFoundException) return Price.NONE;   // recover
        throw new CompletionException(unwrap(ex));                        // propagate the rest
    })
    .whenComplete((p, ex) -> { if (ex != null) log.warn("quote failed for {}", sku, ex); });
HandlerReceivesChanges the outcome?
exceptionally(fn)the exception onlyyes, returns a fallback value
exceptionallyCompose(fn) (JDK 12)the exceptionyes, returns a fallback future
handle(fn)value and exception, one is nullyes, always runs
whenComplete(fn)value and exceptionno, observes and passes the outcome through

The classic bug is a chain with no handler and nobody calling join(). The exception is stored in a future no one reads, and nothing is ever logged. Every fire-and-forget chain must end in a handler that logs. join() throws the unchecked CompletionException; get() throws the checked ExecutionException.

Timeouts and cancellation

JDK 9 added orTimeout(d, unit), which completes the future with TimeoutException if it is still pending, and completeOnTimeout(value, d, unit), which completes it with a fallback. Both only complete the future. They do not stop the underlying work, which keeps running and holds its thread and connection. Pair them with a client-side timeout on the actual call.

Cancellation is similar. cancel(true) completes the future with CancellationException; its mayInterruptIfRunning argument has no effect, the running task is not interrupted, and cancellation does not travel upstream to the futures it depends on. If the work must stop, the code doing it has to check a flag or use a cancellable client.

Retries compose the same way. CompletableFuture.delayedExecutor(d, unit, ex) returns an executor that runs tasks after a delay, so a retry with backoff is a recursive function: on failure, exceptionallyCompose schedules the next attempt on a delayed executor with a doubled delay, until an attempt limit is reached. Retry only idempotent calls, add jitter so many clients do not retry in step, and keep the total inside the caller's timeout budget.

Worked example: a product page with a 300 ms budget

A product page needs the product, its price and stock from three services, plus recommendations that are nice to have. The budget is 300 ms. Product is required; price and stock fall back; recommendations are dropped if late.

private final ExecutorService io = Executors.newFixedThreadPool(64);   // bounded, for blocking clients

CompletableFuture<ProductPage> page(String sku) {
    var product = CompletableFuture.supplyAsync(() -> catalog.get(sku), io)
        .orTimeout(250, MILLISECONDS);                                    // required: fail if late

    var price = CompletableFuture.supplyAsync(() -> pricing.quote(sku), io)
        .completeOnTimeout(Price.UNKNOWN, 200, MILLISECONDS)
        .exceptionally(ex -> Price.UNKNOWN);

    var stock = CompletableFuture.supplyAsync(() -> inventory.level(sku), io)
        .completeOnTimeout(Stock.UNKNOWN, 200, MILLISECONDS)
        .exceptionally(ex -> Stock.UNKNOWN);

    var recs = product.thenCompose(p -> recommender.forCategoryAsync(p.category()))
        .completeOnTimeout(List.of(), 280, MILLISECONDS)
        .exceptionally(ex -> List.of());

    return product
        .thenCombine(price, ProductPage::new)
        .thenCombine(stock, ProductPage::withStock)
        .thenCombine(recs, ProductPage::withRecommendations)
        .whenComplete((pg, ex) -> { if (ex != null) log.error("page {} failed", sku, unwrap(ex)); });
}

Trace it. The three calls start at once on the I/O pool, so the latency is the slowest call, not the sum. Suppose catalog answers in 40 ms, pricing in 90 ms, and inventory hangs. At 200 ms stock completes with Stock.UNKNOWN. Recommendations start at 40 ms, when the product arrives, because thenCompose chains them; if they finish by 280 ms they appear, otherwise the list is empty. The page completes at about 200 ms if recommendations are back by then, and by about 280 ms otherwise, with stock marked unknown. If the catalog fails, product fails, every thenCombine skips its function, and the caller gets one exception, logged once.

Note what this does not do: the hung inventory call still holds an I/O thread until its own client timeout fires. Without that timeout, a slow dependency fills the 64-thread pool and every page then waits for a free thread. That is why the pool is bounded and the clients have their own timeouts.

Testing asynchronous pipelines

Asynchronous code is testable deterministically if you control completion. Inject the dependencies as functions returning futures, hand the test incomplete futures, and complete them in the order the scenario needs. No sleeps, no real threads:

@Test
void stockFallsBackWhenInventoryFails() {
    var catalogF = new CompletableFuture<Product>();
    var priceF   = new CompletableFuture<Price>();
    var stockF   = new CompletableFuture<Stock>();
    var recsF    = new CompletableFuture<List<Product>>();
    var svc = new PageService(sku -> catalogF, sku -> priceF, sku -> stockF,
                              category -> recsF, Runnable::run);

    CompletableFuture<ProductPage> page = svc.page("sku-1");
    assertFalse(page.isDone());                       // nothing completed yet

    stockF.completeExceptionally(new IOException("inventory down"));
    priceF.complete(new Price(1999));
    catalogF.complete(new Product("sku-1", "Kettle"));
    recsF.complete(List.of());

    ProductPage result = page.join();                 // already complete: does not block
    assertEquals(Stock.UNKNOWN, result.stock());
}

Passing Runnable::run as the executor makes every async stage run inline on the test thread, so assertions see the final state. It does not cover timeouts: orTimeout and completeOnTimeout fire on a JDK-internal scheduler thread, not on the injected executor. For timeout paths, inject the timeout values and use short ones, or complete the dependency future with TimeoutException yourself and assert on the fallback. Since JDK 19, state(), resultNow() and exceptionNow() on Future make assertions on completed futures clearer than catching join()'s wrapper.

Failure modes

  • Silent failure. No terminal handler, nobody joins: errors vanish. Always end with whenComplete or handle that logs.
  • Common pool starvation. Blocking I/O in supplyAsync without an executor stalls parallel streams elsewhere in the JVM. Pass an executor.
  • Event-loop hijack. Slow non-async callbacks on a future completed by a network thread. Use the async variant.
  • Blocking inside the pipeline. Calling join() inside a callback parks a pool thread waiting for work that may need the same pool: a deadlock in a bounded pool. Use thenCompose.
  • Timeouts that do not cancel. orTimeout frees the caller but not the resource. Time out the client call as well.
  • Lost context. MDC and trace IDs missing in logs from callbacks. Propagate context in a wrapping executor.
  • Unbounded fan-out. Mapping a list of 10,000 IDs to supplyAsync calls queues 10,000 tasks and can flood a downstream service. Batch the IDs, or limit in-flight calls with a Semaphore acquired before each call and released in whenComplete.

Virtual threads and structured concurrency

Since JDK 21, virtual threads make blocking cheap: a blocked virtual thread releases its carrier. Much code that used CompletableFuture only to avoid parking a platform thread reads better as straight-line blocking code in one virtual thread per task.

For fan-out with cancellation of the losers and automatic error propagation, structured concurrency fits better: StructuredTaskScope cancels sibling tasks when one fails, which CompletableFuture does not. Its API has changed across preview releases, so check that page for the version you run. CompletableFuture remains the right tool for bridging callback APIs, for libraries whose interfaces already return CompletionStage, and for long-lived pipelines where a stage completes far from where it started. See Java concurrency fundamentals for the memory-model background.

What to do next

  1. Search your code for supplyAsync( and runAsync( without an executor and give every blocking one a bounded pool.
  2. Make sure every chain ends in a handler that logs, and unwrap CompletionException before checking types.
  3. Add orTimeout or completeOnTimeout to every remote call and a matching client timeout underneath.
  4. Replace thenApply returning a future with thenCompose, and any join() inside a callback.
  5. Use async variants for callbacks attached to futures completed by network threads.
  6. For new request-scoped fan-out code, prototype it with virtual threads and compare readability.
Key takeaway: CompletableFuture is a result cell with dependents fired on completion. Use thenCompose for async steps, give every blocking stage its own bounded executor, end every chain with a logging handler, and remember that timeouts and cancellation complete the future without stopping the work. For request-scoped fan-out on JDK 21 and later, virtual threads are often simpler.