Senior 8 min · March 06, 2026

CompletableFuture vs Future — Why get() Blocked 200 Threads

Future.get() blocked 200 request threads, spiking latency from 50ms to 8s.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Core: CompletableFuture extends Future with non-blocking composition
  • Future.get() blocks the calling thread — dangerous in high-throughput systems
  • CompletableFuture enables declarative pipelines: thenApply, thenCompose, allOf
  • Performance: CompletableFuture can reduce thread pool usage by 70% under load
  • Production gotcha: default ForkJoinPool.commonPool() is shared — risks subtle contention
  • Biggest mistake: calling get() inside a pipeline — that reintroduces blocking
✦ Definition~90s read
What is CompletableFuture vs Future?

CompletableFuture and Future are Java interfaces for representing the result of an asynchronous computation. Future, introduced in Java 5, is a simple handle that lets you check if a task is done (isDone()) or block until it completes (get()). The problem is that get() is a synchronous blocking call — if you call it on a thread pool with 200 threads, all 200 threads can be parked waiting for results, wasting resources and potentially causing thread starvation or deadlock. Future also offers no way to chain tasks, combine results, or handle errors without manual polling or wrapping in yet more blocking calls.

Imagine you order a pizza.

CompletableFuture, added in Java 8, solves this by providing a fully non-blocking, composable API. Instead of calling get(), you attach callbacks like thenApply(), thenCompose(), or whenComplete() that execute asynchronously when the future completes.

This means your threads never block — they submit work and move on. You can build complex async pipelines, combine multiple futures with allOf or anyOf, and handle exceptions with exceptionally() or handle(). Under the hood, CompletableFuture uses the common ForkJoinPool by default, but you can supply your own Executor to control thread pool sizing — a critical choice, as the default pool can silently throttle your throughput.

The real-world impact is dramatic. A typical REST API gateway that calls three downstream services with Future.get() on a 200-thread pool will have all 200 threads blocked waiting for I/O, achieving maybe 200 concurrent requests. With CompletableFuture and non-blocking composition, the same 200 threads can handle thousands of concurrent requests because they're never parked — they just schedule callbacks.

The trade-off is that CompletableFuture requires a mindset shift from imperative blocking to reactive chaining, and misusing thread pools (e.g., using the common pool for blocking I/O) can silently degrade performance. Use CompletableFuture when you need non-blocking async composition; stick with Future only for simple fire-and-forget tasks where you're okay with blocking.

Plain-English First

Imagine you order a pizza. With a regular Future, you stand at the door staring at the road, completely blocked, doing nothing else until the delivery guy shows up. With CompletableFuture, you hand the delivery guy your phone number, go watch TV, and he calls you when he's two minutes away — you can even tell him in advance: 'when you arrive, also grab my mail from the letterbox.' That callback chain, that ability to compose actions and react without blocking, is exactly what CompletableFuture gives you over a plain Future.

Concurrency bugs are the hardest kind to debug in production. They hide in timing, they only appear under load, and they cost real money. Java's Future interface, introduced in Java 5, was a genuine step forward — but it left developers with a sharp edge they kept cutting themselves on: you couldn't react to a result without blocking a thread. In a high-throughput microservice handling thousands of simultaneous requests, blocking threads is exactly the kind of waste that tanks your throughput and inflates your infrastructure bill.

Java 8 shipped CompletableFuture as the answer. It's not just 'Future but better' — it's a fundamentally different mental model. You stop thinking about 'waiting for a value' and start thinking about 'pipelines of transformations.' The difference is the same as the difference between a synchronous REST call that hangs your thread and a reactive callback chain that frees your thread the moment the work is dispatched. Under the hood, CompletableFuture implements both Future and CompletionStage, giving you backward compatibility while unlocking a rich combinator API.

By the end of this article you'll be able to: explain precisely why Future.get() is dangerous in production, build non-blocking async pipelines with CompletableFuture including fan-out and fan-in patterns, handle exceptions without swallowing them silently, reason about which thread pool is actually executing your callbacks, and answer the tricky interview questions that trip up even experienced Java engineers.

Why CompletableFuture Replaced Future — The Blocking Trap

Java's Future represents an asynchronous result, but its get() method blocks the calling thread until the result is available. This synchronous wait turns async code into a thread-per-task model. In contrast, CompletableFuture provides a non-blocking, callback-driven API that composes async operations without occupying threads. The core mechanic: instead of blocking, you attach lambdas that execute when the future completes, freeing the thread to handle other work. This is not a minor API difference — it changes how you scale. With Future, 200 concurrent calls to get() can consume 200 threads, each blocked waiting. With CompletableFuture, those same 200 calls use a small thread pool (e.g., ForkJoinPool.commonPool()) and only occupy threads during actual computation, not while waiting for I/O or other services. The result: dramatically lower memory footprint, higher throughput, and no thread starvation under load. Use CompletableFuture when composing multiple async calls, implementing timeouts, or building reactive pipelines. Use Future only when you need a simple one-shot async result and can afford blocking — typically in low-concurrency or batch scenarios.

Blocking get() is the silent killer
Future.get() blocks the calling thread indefinitely unless you use the timeout variant. In production, this leads to thread pool exhaustion and cascading failures.
Production Insight
A payment service using Future.get() with 200 concurrent requests exhausted the 200-thread pool, causing all subsequent requests to queue and time out after 30 seconds.
Symptom: Thread dumps showed 200 threads parked at Future.get(), zero threads doing actual work, and new requests failing with RejectedExecutionException.
Rule: Never call Future.get() in a request-handling thread — always use CompletableFuture with thenApply/exceptionally to stay non-blocking.
Key Takeaway
Future.get() blocks the calling thread — it's synchronous masquerading as async.
CompletableFuture enables true non-blocking composition via callbacks and combinators.
Prefer CompletableFuture for any I/O-bound or multi-step async workflow in production.
CompletableFuture vs Future: Async Pipeline Evolution THECODEFORGE.IO CompletableFuture vs Future: Async Pipeline Evolution From blocking get() to non-blocking async composition Future.get() Blocks Thread 200 threads stuck waiting for results CompletableFuture Non-Blocking thenApply/thenCompose chains without blocking Exception Handling exceptionally/handle for error recovery Combine Tasks: allOf/anyOf Wait for multiple async results Thread Pool Pitfall Default ForkJoinPool vs custom executor Async Workflow Without Callbacks Compose pipelines, avoid callback hell ⚠ Future.get() blocks thread, causing thread starvation Use CompletableFuture with non-blocking chaining THECODEFORGE.IO
thecodeforge.io
CompletableFuture vs Future: Async Pipeline Evolution
Completablefuture Vs Future Java

The Problem with Future: Blocking Gets and No Composition

Java's Future was designed for parallelism but not for composition. Once you call Future<V>.get(), the calling thread blocks until the result is available. In a servlet container with a fixed thread pool, even a single get() call that takes 2 seconds blocks one thread for 2 seconds. With 200 threads, throughput collapses to 100 requests/second, no matter how many CPUs you have.

Worse, Future offers no way to chain async operations. To fetch an order, then compute a discount, then send an email, you have to wait for each step and explicitly delegate to new tasks. That leads to nested callbacks, awkward error handling, and thread leaks.

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

import java.util.concurrent.*;

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

        // Bad: blocks the main thread
        Future<String> future = executor.submit(() -> {
            Thread.sleep(2000);
            return "Order-123";
        });

        String result = future.get();  // main thread blocked for 2 seconds
        System.out.println("Result: " + result);

        executor.shutdown();
    }
}
Output
// After 2 seconds: Result: Order-123
Thread starvation warning
Never call Future.get() on the request thread in a web server. It converts your async code into synchronous blocking, defeating the entire purpose.
Production Insight
In production, a single Future.get() inside a request handler can cause thread pool exhaustion under load.
Even with a short timeout (500ms), you're still blocking — just for less time. That thread can't serve other requests.
Rule: if you see a pattern like executor.submit(...).get(), refactor to CompletableFuture.
Key Takeaway
Future.get() is blocking — it turns async into sync.
Never use it on a request thread.
Use CompletableFuture for non-blocking composition.
When to use Future vs CompletableFuture
IfYou need a one-off async result and blocking is acceptable (e.g., batch processing)
UseFuture is sufficient — simpler API
IfYou need chaining, error handling, or reactive callbacks
UseUse CompletableFuture
IfYou're inside a web server or reactive framework
UseAlways CompletableFuture — never Future.get()

CompletableFuture: Non-Blocking Async Pipelines

CompletableFuture introduces a declarative model: instead of waiting for values, you define what should happen when they arrive. The method thenApply() transforms a result when it's ready, without blocking. thenCompose() handles chaining when the transformation itself returns a CompletableFuture. Both run on a thread from the common ForkJoinPool by default, but you can provide a custom executor.

This pattern lets you express async workflows as readable pipelines. Each stage runs only when its input is ready, and all stages share the same thread pool efficiently.

io/thecodeforge/concurrency/CompletableFuturePipeline.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
package io.thecodeforge.concurrency;

import java.util.concurrent.*;

public class CompletableFuturePipeline {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(4);

        CompletableFuture.supplyAsync(() -> fetchOrderFromDB(), executor)
            .thenApply(order -> computeDiscount(order))
            .thenAccept(discounted -> sendEmail(discounted))
            .exceptionally(ex -> {
                System.err.println("Pipeline failed: " + ex.getMessage());
                return null;
            })
            .thenRun(() -> executor.shutdown());

        System.out.println("Main thread is free!");
        // Output: Main thread is free!
        // (order printed later from callback)
    }

    static String fetchOrderFromDB() {
        simulateDelay(1000);
        return "Order-456";
    }
    static String computeDiscount(String order) {
        return order + " with 10% discount";
    }
    static void sendEmail(String message) {
        simulateDelay(500);
        System.out.println("Email sent: " + message);
    }
    static void simulateDelay(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}
Output
Main thread is free!
Email sent: Order-456 with 10% discount
Production Insight
Without a custom executor, stages run on ForkJoinPool.commonPool(). If your app uses parallel streams or other async operations, this pool gets shared, causing resource contention.
Always supply a dedicated executor for mission-critical pipelines.
Use thenApplyAsync() if you need guaranteed execution on a different thread.
Key Takeaway
thenApply transforms; thenCompose flattens nested futures.
Supply a dedicated executor to avoid common pool starvation.
Async+ suffix forces a thread switch — use it for long or blocking callbacks.

Exception Handling in Async Pipelines

Exception handling in CompletableFuture is explicit. The exceptionally() method catches any exception from the preceding stage and allows you to return a fallback value. handle() is a more flexible variant that receives both result and exception, letting you map successes and failures. Neither swallows the exception silently — you must return something.

One common production mistake: forgetting that an unhandled exception in a stage silently completes the future exceptionally. If no downstream handler exists, that exception disappears, leading to hard-to-diagnose failures.

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

import java.util.concurrent.*;

public class ExceptionHandlingExample {
    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> {
            if (Math.random() > 0.5) throw new RuntimeException("DB timeout");
            return "Data";
        })
        .thenApply(data -> data + " processed")
        .exceptionally(ex -> {
            System.err.println("Exception: " + ex.getMessage());
            return "Fallback: cached data";
        })
        .thenAccept(System.out::println)
        .join(); // only for demonstration, normally avoid
    }
}
Output
// About 50% of runs: Exception: DB timeout
// Fallback: cached data
Production Insight
If you use thenApply without attaching exceptionally, an exception in the pipeline propagates silently. The CompletableFuture chain stops, and no callback runs.
Always attach a terminal handler (exceptionally, whenComplete, or handle) to every async pipeline in production.
Use handle() when you need to decide fallback vs re-throw conditions.
Key Takeaway
Unhandled exceptions in CompletableFuture pipelines are lost.
Attach exceptionally() to every chain in production.
handle() gives you both result and exception in one callback.

Combining Multiple Async Tasks: allOf and anyOf

Real systems often need to wait for several independent async results. allOf() returns a CompletableFuture<Void> that completes when all provided futures complete. But it doesn't aggregate their results — you must combine them manually using a collector. anyOf() completes when the first future completes, returning its result.

A common performance trap: passing thousands of futures to allOf() without batching. The allOf implementation registers a completion callback on each future. With 10,000 futures, that's 10,000 callbacks and significant memory pressure. Instead, batch into groups.

io/thecodeforge/concurrency/AllOfExample.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
package io.thecodeforge.concurrency;

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

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

        // Simulate 3 independent API calls
        CompletableFuture<String> product = CompletableFuture.supplyAsync(() -> "Laptop", executor);
        CompletableFuture<Double> price = CompletableFuture.supplyAsync(() -> 1200.0, executor);
        CompletableFuture<Integer> stock = CompletableFuture.supplyAsync(() -> 50, executor);

        CompletableFuture<Void> combined = CompletableFuture.allOf(product, price, stock);

        // Aggregate results
        CompletableFuture<String> result = combined.thenApply(v ->
            product.join() + " - $" + price.join() + " - Stock: " + stock.join()
        );

        System.out.println(result.get()); // Output: Laptop - $1200.0 - Stock: 50
        executor.shutdown();
    }
}
Output
Laptop - $1200.0 - Stock: 50
Production Insight
For thousands of tasks, allOf() creates O(n) internal completion callbacks. If the tasks are numerous, batch them into groups of 100–500.
Use anyOf() for racing requests (e.g., call two services, use the faster response).
But anyOf() doesn't cancel the slower futures — you must handle that explicitly with completeOnTimeout.
Key Takeaway
allOf() waits for all, but doesn't collect results — you must join manually.
anyOf() races futures; the loser keeps running unless cancelled.
Batch large allOf() calls to avoid memory pressure from internal callbacks.

Thread Pool Selection: The Silent Pitfall

By default, CompletableFuture's async methods (supplyAsync, thenApplyAsync, etc.) use ForkJoinPool.commonPool(). This pool is shared across all CompletableFutures in the JVM, plus parallel streams, plus other libraries. Its parallelism defaults to Runtime.availableProcessors() - 1. In a containerised environment (e.g., 2 CPU cores), that's just 1 worker thread. One long-running callback can starve every other async operation in the system.

Always supply a dedicated ExecutorService for production async pipelines. Size it based on I/O vs. CPU workload. For I/O-heavy work, larger pools (2x-4x core count) improve throughput. For CPU-bound work, match core count.

io/thecodeforge/concurrency/DedicatedExecutorExample.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
package io.thecodeforge.concurrency;

import java.util.concurrent.*;

public class DedicatedExecutorExample {
    public static void main(String[] args) {
        // For I/O-heavy tasks: use a pool sized for concurrency, not cores
        ExecutorService ioPool = Executors.newFixedThreadPool(50);

        CompletableFuture.supplyAsync(() -> fetchFromAPI(), ioPool)
            .thenApplyAsync(data -> transformWithCPU(data), ioPool)
            .thenAcceptAsync(System.out::println, ioPool)
            .exceptionally(ex -> {
                System.err.println("Error: " + ex.getMessage());
                return null;
            })
            .thenRun(() -> ioPool.shutdown());

        // Main thread remains free
    }

    static String fetchFromAPI() {
        // Simulate network call
        return "{\"status\":\"ok\"}";
    }
    static String transformWithCPU(String data) {
        // Simulate CPU compute
        return data.toUpperCase();
    }
}
Output
{"STATUS":"OK"}
Production Insight
The common ForkJoinPool is a concealed dependency. If your app uses multiple async libraries, they all compete for the same small pool.
In production, always create a dedicated executor for each subsystem.
For long-running tasks, supply an executor with a sufficiently large queue — or risk RejectedExecutionException.
Key Takeaway
Default pool = ForkJoinPool.commonPool() — shared, small, risky.
Always pass an explicit executor for production pipelines.
Size I/O pools large; size CPU pools equal to cores.

The Hidden Cost: Future.get() and Thread Starvation

Most devs think ExecutorService.submit() gives them free parallelism. It doesn't. Every Future.get() blocks the calling thread until the async task completes. Block a thread in a pool and you've created a latent starvation bomb.

Here's how it blows up. Say you have a 10-thread pool. You submit 10 tasks that each internally call Future.get() on another async call. Now all 10 threads are parked waiting for responses. The queue fills. New tasks get rejected or wait indefinitely. Your throughput tanks.

CompletableFuture solves this by removing the blocking call entirely. Instead of waiting on a result, you chain callbacks. The thread that submitted the work gets released back to the pool immediately. No parked threads. No cascading failures.

If you see Future.get() inside a thread pool worker, you've likely introduced a threading deadlock waiting to happen. The fix isn't more threads — it's non-blocking composition.

ThreadStarvationDemo.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
// io.thecodeforge — java tutorial

import java.util.concurrent.*;

public class ThreadStarvationDemo {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2);
        
        // Bad: worker blocks waiting on another future
        Callable<String> blockingWork = () -> {
            Future<String> inner = pool.submit(() -> "result");
            return inner.get(); // blocks this thread!
        };
        
        // Submit 2 tasks — they both block, pool is deadlocked
        Future<String> f1 = pool.submit(blockingWork);
        Future<String> f2 = pool.submit(blockingWork);
        
        // Next task never runs — all threads parked
        Future<String> f3 = pool.submit(() -> "stuck");
        
        System.out.println(f1.get()); // may never print
        pool.shutdown();
    }
}
Output
(No output — hangs indefinitely due to deadlock)
Production Trap:
Never call Future.get() inside a task submitted to the same pool. You've just created a self-deadlock. Use CompletableFuture.thenApply() or supplyAsync() with a different pool instead.
Key Takeaway
Blocking a thread in a pool steals a resource; chaining callbacks releases it. If you call Future.get() in a worker, you're doing it wrong.

Composing Async Workflows Without Callback Hell

Future forces you into ugly patterns. Need to call service A, then pass its result to service B? You either nest Future.get() calls (ugly and blocking) or use Guava's ListenableFuture (external dependency). Neither is clean.

CompletableFuture gives you thenCompose() — a flatMap for async operations. It chains dependent calls without nesting or blocking. Each stage runs when the previous completes, using the same or a different thread pool. No manual thread management.

This matters in real services. User logs in → fetch profile → load permissions → hydrate dashboard. With Future, you'd block at each step. With CompletableFuture, you compose the pipeline as a single expression. The pipeline is lazy; it only starts when you call join() or get().

The secret: thenCompose() returns a new CompletableFuture that unwraps the inner future. You never deal with Future<Future<Response>>. The type system keeps you honest.

AsyncCompositionDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — java tutorial

import java.util.concurrent.*;

public class AsyncCompositionDemo {
    static CompletableFuture<String> fetchUser(String id) {
        return CompletableFuture.supplyAsync(() -> "user_" + id);
    }
    
    static CompletableFuture<String> loadPermissions(String user) {
        return CompletableFuture.supplyAsync(() -> user + "_perms");
    }
    
    public static void main(String[] args) {
        CompletableFuture<String> result = fetchUser("42")
            .thenCompose(AsyncCompositionDemo::loadPermissions)
            .thenApply(perms -> "granted:" + perms);
        
        System.out.println(result.join());
        // Output: granted:user_42_perms
    }
}
Output
granted:user_42_perms
Senior Shortcut:
Use thenCompose() when the next stage depends on the previous result. Use thenCombine() when two tasks are independent. Mixing them up creates unnecessary sequential bottlenecks.
Key Takeaway
thenCompose() flattens nested async calls into a single pipeline. No blocking, no nesting, no callback hell.

Timeout Control: orTimeout() vs completeOnTimeout()

Raw Future.get() with a timeout throws TimeoutException and leaves the future running in the background. CompletableFuture gives you two precise tools. orTimeout(long, TimeUnit) wraps a CF to throw a TimeoutException if it doesn’t finish in time. It does not cancel the original task — the thread may keep working wastefully. completeOnTimeout(defaultValue, long, TimeUnit) avoids the exception entirely: on timeout, it forces the CF to complete with a fallback value. This prevents pipeline crashes but masks failures. Choose orTimeout when a timeout means a true error. Choose completeOnTimeout for retry logic or default values in resilient flows. Both return a new CF, so you chain them mid-pipeline without blocking.

TimeoutExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — java tutorial

CompletableFuture<String> task =
    CompletableFuture.supplyAsync(() -> {
        sleep(2000); // simulate slow work
        return "Done";
    });

// throws TimeoutException if > 1 sec
CompletableFuture<String> withError =
    task.orTimeout(1, TimeUnit.SECONDS);

// fallback value on timeout
CompletableFuture<String> withFallback =
    task.completeOnTimeout("Fallback", 1, TimeUnit.SECONDS);

System.out.println(withFallback.join()); // "Fallback"
Output
Fallback
Production Trap:
orTimeout does NOT cancel the underlying task. The thread still runs to completion, wasting resources. Pair it with cancel(true) only if you control the async supplier.
Key Takeaway
Use orTimeout for strict deadlines and exceptions; use completeOnTimeout for graceful degradation without exceptions.

Factory Methods: failedFuture() and completedStage()

Creating pre-completed or pre-failed futures avoids redundant try-catch blocks in callers. failedFuture(Throwable ex) returns a CompletableFuture that is already completed exceptionally. This is perfect for early exits in validation or routing logic. completedStage(value) returns a CompletionStage (not CompletableFuture) — a read-only view. The caller cannot call complete() or obtrudeValue() on a stage, enforcing immutability. failedStage(ex) is the exceptional counterpart. Use these factory methods instead of manually wrapping values with CompletableFuture.completedFuture() when you want to prevent downstream mutation. They reduce boilerplate and make failure paths explicit at the construction point.

FactoryMethods.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
// io.thecodeforge — java tutorial

CompletableFuture<String> valid =
    CompletableFuture.completedFuture("OK");

CompletableFuture<String> invalid =
    CompletableFuture.failedFuture(
        new IllegalArgumentException("Bad input"));

CompletionStage<String> readOnly =
    CompletableFuture.completedStage("Read only");

// readOnly.toCompletableFuture().complete("Hack"); // ClassCastException at runtime
Output
// No output — futures complete immediately without computation
Production Trap:
completedStage() returns a CompletionStage, not a CompletableFuture. Trying to downcast it will throw ClassCastException. Design your public APIs to return CompletionStage when callers should not mutate the future.
Key Takeaway
Prefer failedFuture() for early failures and completedStage() to expose immutable completion stages to external callers.

Custom Async Execution: completeAsync() and delayedExecutor()

completeAsync(Supplier, Executor) asynchronously completes a CompletableFuture with the result of a supplier. Unlike supplyAsync, it fires the supplier exactly once and ties it to an existing, possibly incomplete future. This is useful for deferred or one-shot async resolutions. delayedExecutor(long, TimeUnit, Executor) returns an executor that schedules tasks after a delay. Combine it with completeAsync to build delayed retries or scheduled fallbacks without external schedulers. The delayed executor reuses your existing thread pool instead of spawning new timers. This avoids hidden thread leaks common with ScheduledExecutorService. Both methods keep your pipeline non-blocking and resource-efficient.

AsyncExecutor.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
// io.thecodeforge — java tutorial

Executor delayed = CompletableFuture.delayedExecutor(
    500, TimeUnit.MILLISECONDS, ForkJoinPool.commonPool());

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

future.completeAsync(() -> {
    System.out.println("Running in delayed pool");
    return "Delayed result";
}, delayed);

System.out.println(future.join()); // waits ~500ms
Output
Running in delayed pool
Delayed result
Production Trap:
delayedExecutor() does not propagate exceptions from the delay. A failed delay (e.g., interrupted sleep) silently swallows the task. Wrap your supplier in a try-catch and complete the future exceptionally.
Key Takeaway
Use completeAsync with a delayedExecutor to schedule one-shot async tasks without creating separate timer threads.

Overview

Java's original Future interface (java.util.concurrent.Future) represents the result of an asynchronous computation, but it forces blocking with get() and lacks native composition. CompletableFuture, introduced in Java 8, solves these limitations by enabling non-blocking callbacks, declarative pipelines, and explicit exception handling without thread blocking. The core difference is architectural: Future is a 'pull' model where you must actively block to retrieve results, while CompletableFuture is a 'push' model where callbacks fire automatically upon completion. This shift eliminates the need for manual polling and enables fluent chaining of dependent async tasks (thenApply, thenCompose), parallel aggregation (allOf, anyOf), and timeout control. Understanding this distinction is critical for building scalable, responsive Java applications. The table below highlights the key contrasts between the two APIs.

FutureVsCompletableFuture.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — java tutorial
// Blocking Future vs non-blocking CompletableFuture
import java.util.concurrent.*;

public class FutureVsCompletableFuture {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(1);
        
        // Old Future: blocks main thread
        Future<Integer> future = pool.submit(() -> 42);
        int result = future.get(); // Blocks!
        
        // CompletableFuture: non-blocking pipeline
        CompletableFuture.supplyAsync(() -> 42)
            .thenApply(n -> n * 2)
            .thenAccept(System.out::println);
        
        pool.shutdown();
    }
}
Output
Example comparison output would print 84 from CompletableFuture (non-blocking)
Production Trap:
Future.get() with no timeout in high-load systems can cause thread starvation. Always prefer CompletableFuture with orTimeout() or use asynchronous completion callbacks instead.
Key Takeaway
Future is a blocking pull model; CompletableFuture is a non-blocking push model.

RxJava's Observable

While CompletableFuture handles single async results, RxJava's Observable extends the reactive paradigm to streams of zero-to-N items with backpressure support. This makes Observable ideal for event-driven workflows like UI updates, sensor data, or paginated API responses. The critical difference: CompletableFuture represents a one-shot async value (monad), whereas Observable is a push-based collection (observable stream). For single async results, CompletableFuture is simpler and more performant. But for multiple emissions over time, Observable provides operators like map, filter, merge, and flatMap that elegantly handle continuous data flows. Choose CompletableFuture when you need future composition; choose Observable when you need reactive streams with time-based or backpressured operations. RxJava's Observable also integrates cancellation more natively through Subscription management.

RxJavaObservableExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — java tutorial
// Single value: CompletableFuture vs Observable stream
import io.reactivex.rxjava3.core.Observable;
import java.util.concurrent.CompletableFuture;

public class RxJavaObservableExample {
    public static void main(String[] args) {
        // CompletableFuture: one async result
        CompletableFuture.supplyAsync(() -> "data")
            .thenAccept(System.out::println);

        // Observable: stream of multiple emissions
        Observable.just(1, 2, 3)
            .map(n -> n * 10)
            .subscribe(System.out::println);
    }
}
Output
CompletableFuture prints 'data'; Observable prints 10, 20, 30
Production Trap:
Never use Observable for single async results—CompletableFuture is lighter and avoids the overhead of reactive streams infrastructure.
Key Takeaway
CompletableFuture is for single values; Observable is for streams of multiple items.
● Production incidentPOST-MORTEMseverity: high

The Blocking get() That Exhausted a Thread Pool

Symptom
Average response time jumped from 50ms to 8s under 50% load. Thread pool metrics showed all 200 threads active, 0 idle.
Assumption
The team assumed that because they used ExecutorService, the API was non-blocking.
Root cause
A developer called future.get(500, TimeUnit.MILLISECONDS) inside an async servlet, which blocked the request thread. With 200 threads and each call taking ~2 seconds, the pool saturated. New requests queued indefinitely, eventually timing out.
Fix
Replaced Future.get() with CompletableFuture.supplyAsync() + thenApply(). The blocking was eliminated; threads were freed to handle other work. The response time dropped back to 50ms.
Key lesson
  • Future.get() always blocks the calling thread — never use it in a web server thread.
  • CompletableFuture lets you attach callbacks without blocking.
  • If you see all threads blocked in Thread::park or LockSupport.parkNanos, look for Future.get().
Production debug guideSymptom → Action steps for Common Future/CompletableFuture Failures4 entries
Symptom · 01
Application threads all blocked in Future.get()
Fix
Take a thread dump (jstack <pid>). Look for 'Waiting on condition' with java.util.concurrent.FutureTask.get(). Replace with CompletableFuture.
Symptom · 02
CompletableFuture stage never executes
Fix
Check the executor. If using default ForkJoinPool.commonPool(), it may be starved. Use a dedicated ExecutorService.
Symptom · 03
CompletableFuture completes but callback doesn't fire
Fix
Ensure the stage is attached before future completes. For already-completed futures, use thenApplyAsync to force a different thread.
Symptom · 04
AllOf/AnyOf waits indefinitely
Fix
Check each component future: if any is stuck (e.g. on a never-ending network call), the combined future will never complete. Add timeouts per future.
★ Quick Debug Cheat SheetImmediate commands and steps for diagnosing async pipeline failures.
Thread pool exhaustion
Immediate action
Take thread dump. Count threads in waiting/blocked state.
Commands
jstack -l <pid> | grep -A 10 'java.util.concurrent.FutureTask'
ps -T -p <pid> | wc -l (to count threads)
Fix now
Replace blocking get() with non-blocking thenApply() or thenAccept(). Use a bounded executor.
Stage never calls callback+
Immediate action
Check if the future was already complete when callback attached.
Commands
Use CompletableFuture.isDone() in a breakpoint or log.
Attach callback before completing future with complete() or exceptionallyComplete().
Fix now
Use thenApplyAsync(..., executor) to ensure callback runs on a separate thread.
Combined future (allOf) never completes+
Immediate action
Log isDone() on each component future.
Commands
Array of futures: each.get() in a loop with small timeout to find the stuck one.
Check network calls or database queries that might never complete.
Fix now
Add timeouts to each component future using completeOnTimeout() or orTimeout() (Java 9+).
Future vs CompletableFuture at a Glance
FeatureFuture (Java 5)CompletableFuture (Java 8)
BlockingYes — get() blocks the calling threadNo — callbacks fire on executor threads
ChainingManual — submit a new task and waitDeclarative — thenApply, thenCompose
Exception handlingCatch ExecutionException around get()exceptionally(), handle(), whenComplete()
Combining multipleManual — awkward with CompletionServiceallOf(), anyOf()
Completing manuallyNot possiblecomplete(), completeExceptionally()
Timeoutsget(timeout, unit) — still blocksorTimeout(), completeOnTimeout() (Java 9+)

Key takeaways

1
Future.get() blocks
treat it as toxic in request threads.
2
CompletableFuture enables declarative non-blocking pipelines.
3
Always attach exception handlers (exceptionally, handle) to every async chain.
4
Provide a dedicated executor for production pipelines
never rely on the common pool.
5
Use allOf for fan-in, but batch large inputs to avoid O(n) callback overhead.
6
Async stages (thenApplyAsync) force a thread switch
use for long or blocking callbacks.

Common mistakes to avoid

5 patterns
×

Calling get() inside a web server thread

Symptom
Thread pool exhaustion under moderate load; thread dumps show many threads blocked on Future.get()
Fix
Replace with CompletableFuture and attach callbacks. Never call get() except in daemon threads or initialisers.
×

Not attaching exception handlers in async chains

Symptom
Silent failures — pipeline stage doesn't execute, and no error is logged
Fix
Always chain exceptionally() or handle() before the terminal operation. Log exceptions there.
×

Using default ForkJoinPool in production web apps

Symptom
Mysterious slowdowns under load; parallel streams also slow down
Fix
Provide a dedicated ExecutorService for each async pipeline or subsystem.
×

Forgetting that thenApply runs on the same thread as the previous stage

Symptom
Long-running callbacks block the executor thread, stalling other tasks
Fix
Use thenApplyAsync() to force a thread switch when callbacks involve blocking operations (I/O, DB).
×

Passing thousands of futures to allOf() without batching

Symptom
Memory pressure from internal callbacks; GC overhead
Fix
Batch futures into groups of 100–500 and combine iteratively.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between Future and CompletableFuture?
Q02SENIOR
How do you combine multiple independent async tasks and process their re...
Q03SENIOR
What are the thread pool implications of using CompletableFuture in a we...
Q04SENIOR
How do you handle timeouts in CompletableFuture?
Q01 of 04JUNIOR

What is the difference between Future and CompletableFuture?

ANSWER
Future (Java 5) represents a result that will be available later. Its get() method blocks until the result is ready. CompletableFuture (Java 8) extends Future and adds the ability to attach callbacks (thenApply, thenAccept) that execute non-blockingly when the result becomes available. It also supports composition (thenCompose, allOf) and explicit completion (complete, completeExceptionally).
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is CompletableFuture vs Future in simple terms?
02
Can I convert a Future to a CompletableFuture?
03
What happens if I never attach a callback to a CompletableFuture?
04
How do I cancel a CompletableFuture?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Java 8+ Features. Mark it forged?

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

Previous
Pattern Matching in Java
14 / 16 · Java 8+ Features
Next
Text Blocks in Java 15