CompletableFuture — get() Inside thenApply() Freezes Pools
8 blocked ForkJoinPool threads froze a payment service.
- 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
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.
get(). Instead, chain with thenApply() or thenAccept().get() on a CompletableFuture inside a web request handler blocks the request thread.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.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.
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.ForkJoinPool.commonPool() or shared pool sized to coresChaining 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.
- 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.
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.
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.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.
- 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.
ForkJoinPool.commonPool() is for CPU tasks only.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.
Executors.newCachedThreadPool() without a bound — threads can grow unbounded, consuming memory and causing thrashing.cancel().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.
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.
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.Thread.sleep() — it's flaky.The Silent Timeout: How a Blocking Get() Took Down a Payment Service
Future.get()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.get() with thenApply()/thenCompose() to chain off the future. If you must wait, use a dedicated thread pool sized for blocking.- Never call
get()orjoin()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.
exceptionally() or handle() at each chaining step. Enable JVM flags -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation for full traces.get() or join(), refactor to avoid blocking.CompletableFuture.get() only at the boundary.get() with thenApply() or supplyAsync() with a custom executor.Key takeaways
get() inside a chain.handle() on every pipeline.ForkJoinPool.commonPool() is for CPU tasks onlyCommon mistakes to avoid
4 patternsUsing get() or join() inside an async chain
Confusing thenApply with thenCompose
Forgetting to handle exceptions at the end of a pipeline
exceptionally() to log and return a sentinel.Using ForkJoinPool.commonPool() for I/O-bound tasks
Executors.newFixedThreadPool() with a bounded queue.Interview Questions on This Topic
What is the difference between thenApply and thenCompose in CompletableFuture?
Frequently Asked Questions
That's Multithreading. Mark it forged?
13 min read · try the examples if you haven't