skip to content

CompletableFuture Pitfalls

The recurring mistakes: blocking inside common-pool callbacks and starving every parallel stream in the JVM, losing exceptions in a future nobody joins, and assuming cancellation propagates through a chain. Interviewers ask about these because they are the ones that actually reach production.

part ofJavaoverview, primer and where to startread it →
on this pageshow

questions

5

Why is doing a blocking call (e.g. a synchronous HTTP request or JDBC query) inside a CompletableFuture callback that runs on the default executor dangerous, and how do you avoid it?

level: middleimportance: must knowfreq 70%

answer

  1. No-executor *Async → common pool
  2. commonPool ≈ cores − 1 threads, shared JVM-wide
  3. Blocking pins a pool thread → starvation
  4. Pass a dedicated bounded executor for I/O
  5. Java 21: virtual-thread executor unmounts on block

basics

~20 s

When you don't pass an executor, callbacks run on the shared ForkJoinPool.commonPool, which has only a few threads. Blocking those threads starves every other task using that pool. Fix it by passing your own dedicated executor to the *Async methods.

solid answer

~50 s

By default, CompletableFuture's async stages run on ForkJoinPool.commonPool(), a JVM-wide pool sized to roughly (CPU cores - 1) threads and shared by parallel streams and other framework code. The common pool is designed for short, CPU-bound, non-blocking work. If a callback performs a blocking I/O call (HTTP, JDBC, file, lock wait), it pins a pool thread for the whole blocking duration. With only a handful of threads, a few concurrent blocking tasks exhaust the pool, so unrelated work queues up and throughput collapses; in the worst case the whole app appears to deadlock. The fix is to never block on the common pool: pass an explicit, appropriately sized Executor (e.g. a bounded thread pool dedicated to I/O) to the *Async overloads, like thenApplyAsync(fn, ioExecutor). On Java 21+, a virtual-thread executor is a strong choice for blocking work since blocking a virtual thread doesn't pin a platform carrier thread.

code

java · 13 lines
java
// PITFALL: blocks the shared common pool
CompletableFuture<String> bad =
    CompletableFuture.supplyAsync(() -> httpClient.getSync(url)); // common pool!

// FIX: dedicated, bounded executor for blocking I/O
ExecutorService ioPool = Executors.newFixedThreadPool(32);
CompletableFuture<String> good =
    CompletableFuture.supplyAsync(() -> httpClient.getSync(url), ioPool);

// Java 21+: virtual threads unmount on block — ideal for blocking work
ExecutorService vts = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<String> best =
    CompletableFuture.supplyAsync(() -> httpClient.getSync(url), vts);

go deeper

for a junior

Knows that callbacks need a thread to run on and that you can pass an executor; can state that blocking is bad but may not know which pool the default uses.

for a middle

Names ForkJoinPool.commonPool() as the default, explains its small fixed size and that blocking it starves other tasks, and fixes it by supplying a dedicated executor.

for a senior

Discusses sizing (CPU-bound vs I/O-bound), bounded pools for backpressure, the global-sharing risk with parallel streams, and reaches for non-blocking clients or virtual threads.

for a principal

Frames it as an executor-ownership and capacity-planning concern across the service; sets org-wide patterns (no blocking on common pool, dedicated bounded I/O pools, virtual-thread adoption), and reasons about cascading starvation across subsystems.

## What CompletableFuture is A **CompletableFuture<T>** is Java's class (since Java 8, in `java.util.concurrent`) for representing the result of an asynchronous computation that will *complete* in the future with either a value or an exception. You build a *pipeline* of stages: `thenApply` transforms the value, `thenCompose` chains another future, `thenAccept` consumes it, `exceptionally`/`handle` deal with errors. Each stage's callback runs *when the previous stage completes*. ## Where does a callback actually run? This is the crux. There are two families of methods: - **Non-`Async` methods** (`thenApply`, `thenAccept`): the callback runs on *whatever thread completed the previous stage* — possibly the thread that called `complete()`, possibly the caller's thread if already complete. You don't control it. - **`Async` methods without an executor argument** (`thenApplyAsync(fn)`): the callback is submitted to the **default executor**, which is `ForkJoinPool.commonPool()`. - **`Async` methods with an executor** (`thenApplyAsync(fn, myExecutor)`): the callback runs on the executor *you* supply. Same for the entry points: `CompletableFuture.supplyAsync(supplier)` uses the common pool; `supplyAsync(supplier, executor)` uses yours. ## What is the common pool? `ForkJoinPool.commonPool()` is a **single, JVM-wide, shared** thread pool. Its default parallelism is `Runtime.getRuntime().availableProcessors() - 1` (so on an 8-core box, ~7 threads). It is *also* used by parallel streams (`list.parallelStream()`), `Arrays.parallelSort`, and other JDK machinery. It was designed for **short, CPU-bound, non-blocking** tasks that quickly hand the thread back. ## The pitfall: blocking on the common pool A *blocking call* is any operation that parks the thread until something external returns — a synchronous HTTP request, a JDBC query, reading a socket/file, `Thread.sleep`, acquiring a contended lock, or `someOtherFuture.get()`. While a thread blocks, it does **no** work but is unavailable to anyone else. Now combine: the common pool has only ~7 threads, and it's shared globally. If you write ```java CompletableFuture.supplyAsync(() -> httpClient.getSync(url)) // common pool! ``` then each in-flight request occupies one of those ~7 threads for its *entire latency*. Fire 50 such requests and the first ~7 run while the other 43 sit in the pool's queue. Worse, a parallel stream elsewhere in the app, or another CompletableFuture chain, now also can't get a thread — your blocking I/O has **starved** unrelated subsystems. If a blocked task is itself waiting (directly or transitively) on *another* task that needs a common-pool thread, you can get a **liveness/deadlock**-like stall. ## How to avoid it 1. **Never block on the common pool.** For anything that does I/O or otherwise blocks, supply a **dedicated, explicitly sized executor**: `thenApplyAsync(fn, ioExecutor)` / `supplyAsync(task, ioExecutor)`. 2. **Size for the workload.** CPU-bound work wants ~#cores threads; blocking I/O wants *more* threads (since they spend most time parked) — but **bounded**, to cap resource use and provide backpressure. 3. **Prefer non-blocking clients** when possible (e.g. an async HTTP client returning a `CompletableFuture`) so no thread is held at all. 4. **Java 21+ virtual threads:** use `Executors.newVirtualThreadPerTaskExecutor()`. A virtual thread that blocks on I/O is *unmounted* from its carrier platform thread, so you can have millions of blocking tasks without exhausting a small pool. This largely dissolves the "don't block" rule for the supplied executor (but you still shouldn't block the *common* pool). ## Key mental model The `*Async` overload with **no executor** is a trap that quietly routes onto the shared common pool. Treat "which executor" as a deliberate decision per blocking stage, not a default you inherit.

  • How is the common pool's default size determined, and how can you change it?
    Default parallelism is availableProcessors() - 1. You can raise it with the system property java.util.concurrent.ForkJoinPool.common.parallelism, but tuning the shared pool is a blunt instrument — preferring a dedicated executor for blocking work is safer.
  • Why do virtual threads change the calculus for blocking inside async stages?
    A virtual thread that blocks on I/O is unmounted from its carrier platform thread, freeing that carrier for other virtual threads. So a virtual-thread-per-task executor can carry huge numbers of blocking tasks without exhausting OS threads — unlike a small fixed pool.

saying these in an interview costs you the question

  • Thinking supplyAsync/thenApplyAsync creates a fresh thread per call rather than using a shared pool
  • Believing the common pool grows unbounded to absorb blocking work
  • Assuming a small per-request blocking call is harmless because 'it's just one request'
  • Confusing the common pool with a per-CompletableFuture private pool

context

open as a page

How can a CompletableFuture silently swallow an exception, and what practices ensure failures are observed?

level: middleimportance: must knowfreq 65%

basics

~20 s

If a stage throws and you never call join()/get() and never add exceptionally()/handle(), the failure is stored inside the future and nobody ever sees it — no log, no crash. Always terminate a chain by either joining it or attaching an error handler.

open as a page

Explain the common misconception about thenApply vs thenApplyAsync ordering. Do the Async variants change the order in which dependent stages execute?

level: seniorimportance: should knowfreq 45%

basics

~20 s

No. thenApply and thenApplyAsync produce the same logical order — each stage still waits for the one before it. The 'Async' suffix only controls which thread the callback runs on, not whether stages run in parallel or reorder.

open as a page

Why does calling cancel() on a CompletableFuture often fail to actually stop the running work or propagate through a chain, and how should cancellation be designed instead?

level: seniorimportance: should knowfreq 40%

basics

~20 s

CompletableFuture.cancel() just completes the future with a CancellationException — it does not interrupt the thread running your task, and downstream-only cancellation doesn't travel back to upstream stages. To really stop work you need cooperative cancellation (check a flag / interrupt) and you must cancel the right (upstream) stages.

open as a page

An I/O-heavy service uses CompletableFuture.supplyAsync without an explicit executor and sees throughput cap far below expectations. Diagnose how common-pool sizing limits parallelism and what you'd change.

level: principalimportance: should knowfreq 38%

basics

~20 s

The default common pool has only about (cores − 1) threads, so at most that many blocking I/O calls run at once — adding more tasks just queues them. For I/O-bound work you want many more threads than cores, so supply a dedicated, larger (bounded) executor, or use virtual threads.

open as a page