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?
answer
- No-executor *Async → common pool
- commonPool ≈ cores − 1 threads, shared JVM-wide
- Blocking pins a pool thread → starvation
- Pass a dedicated bounded executor for I/O
- Java 21: virtual-thread executor unmounts on block
basics
~20 sWhen 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 sBy 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// 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
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.
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.
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.
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