skip to content

Why might you choose CompletableFuture.supplyAsync over ExecutorService.submit, given both run a task on a thread pool and return a Future?

level: seniorimportance: should knowfreq 45%

answer

  1. submit → plain Future = block-only (get/cancel)
  2. supplyAsync → CompletableFuture = CompletionStage = compose without blocking
  3. thenApply/thenCompose/thenCombine/allOf/exceptionally
  4. submit(Callable) allows checked exceptions; Supplier does not
  5. both let you choose the executor

basics

~20 s

Both run a task and return a Future, but ExecutorService.submit gives a plain Future you can only block on with get(). supplyAsync returns a CompletableFuture you can chain non-blocking steps onto (thenApply, thenCompose, combine) and add error recovery.

solid answer

~50 s

ExecutorService.submit(Callable) returns a plain java.util.concurrent.Future: you can cancel it or call get(), which blocks. There's no way to say "when it finishes, transform/combine/handle it" without a thread sitting in get(). CompletableFuture.supplyAsync returns a CompletableFuture, which implements CompletionStage, so you can build a non-blocking pipeline: thenApply to map the result, thenCompose to flatMap into another async call, thenCombine to join two futures, exceptionally/handle for error recovery, allOf/anyOf to fan-in. It also supports manual completion, timeouts (orTimeout), and explicit completion from callbacks. The cost: CompletableFuture's API is large and easy to misuse (the get/join wrapper-exception split, accidental common-pool blocking, swallowed exceptions). If all you need is fire-a-task-and-block-once, submit is simpler. If you're composing asynchronous steps, supplyAsync (and CompletableFuture) is the right tool. You can also pass a custom executor to supplyAsync just like you'd choose a pool for submit.

go deeper

for a junior

Knows both return a Future and run on a pool, and that CompletableFuture lets you chain steps.

for a middle

Lists concrete CompletableFuture composition methods (thenApply/thenCompose) absent from a plain Future and that supplyAsync can take a custom executor.

for a senior

Articulates the non-blocking compose-vs-block-only trade-off, the checked-exception nuance of Supplier vs Callable, and CompletableFuture's footguns; picks the right tool per situation.

for a principal

Weighs declarative async composition vs virtual threads, sets team conventions for executor injection and error handling, and reasons about maintainability/observability of async pipelines at scale.

## Both return a Future — so what's different? ```java ExecutorService pool = Executors.newFixedThreadPool(4); Future<Integer> f = pool.submit(() -> compute()); // plain Future CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> compute(), pool); // CompletableFuture ``` Both schedule `compute()` on a thread pool and hand you a handle to the eventual result. The difference is entirely in **what that handle can do**. ## The plain Future (Java 5) is blocking-only `java.util.concurrent.Future<T>` has essentially four operations: `get()` (block until done), `get(timeout)`, `cancel(...)`, and `isDone()/isCancelled()`. There is **no callback registration**. To use the result you must call `get()`, which *blocks the calling thread* until the task finishes. If you have ten futures, you block, in sequence, ten times. To react to completion you'd need to poll `isDone()` or dedicate a thread to wait. This is the fundamental limitation: a `Future` lets you *retrieve* a result but not *compose* on it. ## CompletableFuture implements CompletionStage — composition `CompletableFuture<T>` is a `Future<T>` **plus** a `CompletionStage<T>`. "Completion stage" is the key: it lets you register what happens *next* without blocking. The vocabulary: - **Transform**: `thenApply(fn)` — map the result `T → U`. - **Chain another async call**: `thenCompose(fn)` — flatMap; when this future completes, start another future and adopt it (avoids `CompletableFuture<CompletableFuture<U>>`). - **Combine two**: `thenCombine(other, biFn)` — wait for two independent futures and merge their results. - **Fan-in**: `CompletableFuture.allOf(...)` (wait for all), `anyOf(...)` (first to finish). - **Consume / run**: `thenAccept(consumer)`, `thenRun(runnable)`. - **Recover**: `exceptionally(fn)`, `handle(biFn)`, `whenComplete(biFn)`. - **Async variants** of each (`thenApplyAsync`, ...) run the next step on a pool rather than the completing thread, with optional custom executor. None of these exist on a plain `Future`. With `submit`, you'd have to block, then call the next thing manually—serial and thread-wasting. ## A concrete contrast Fetch two things and combine them: ```java // Plain Future: blocks twice, serially Future<A> fa = pool.submit(this::getA); Future<B> fb = pool.submit(this::getB); A a = fa.get(); // blocks B b = fb.get(); // blocks Result r = merge(a, b); // CompletableFuture: non-blocking compose, no thread parked CompletableFuture<A> ca = CompletableFuture.supplyAsync(this::getA, pool); CompletableFuture<B> cb = CompletableFuture.supplyAsync(this::getB, pool); CompletableFuture<Result> r = ca.thenCombine(cb, this::merge); ``` The second version never blocks a thread waiting; the merge fires automatically when both finish. ## What submit still has going for it - **Simplicity.** If you only need "run this and block once for the answer," `submit().get()` is less surface area and fewer footguns. - **`invokeAll`/`invokeAny`** convenience for batches when you *do* want to block. - **Callable's checked exceptions.** `submit(Callable)` lets the task throw checked exceptions naturally; `supplyAsync`'s `Supplier` cannot throw checked exceptions, so you must wrap them. ## CompletableFuture's costs / footguns (be honest in an interview) - The **`get()` (ExecutionException, checked) vs `join()` (CompletionException, unchecked)** wrapper split. - **Accidental common-pool blocking** if you forget the executor overload. - **Silently swallowed exceptions** if no terminal handler is attached. - A **large, subtle API** (async vs non-async variants, ordering, which thread runs what). ## The decision rule - **Composing asynchronous steps** (transform, chain, combine, recover, fan-in) → `CompletableFuture`/`supplyAsync`. - **One task, block once for the result** → `ExecutorService.submit` is simpler. - Either way, **choose the executor deliberately**—you can pass a custom pool to `supplyAsync` exactly as you pick a pool for `submit`. ## Note on virtual threads (Java 21+) With virtual threads, blocking `get()` becomes cheap, which narrows the "don't block" argument for `submit`. But `CompletableFuture`'s value is also *expressing dependencies between steps declaratively*, which virtual threads alone don't give you. The two are complementary.

  • Name a capability CompletableFuture has that a plain Future from submit does not.
    Non-blocking composition: registering callbacks/transformations (thenApply, thenCompose, thenCombine), fan-in (allOf/anyOf), and error recovery (exceptionally/handle) that fire on completion without blocking a thread.
  • Is there any case where submit is the better choice?
    Yes—when you just run one task and block once for the result, submit().get() is simpler with fewer footguns; submit(Callable) also lets the task throw checked exceptions, and invokeAll/invokeAny handle batches you intend to block on.

saying these in an interview costs you the question

  • Claiming a plain Future supports callbacks/chaining (it only blocks via get)
  • Saying CompletableFuture is always better—ignoring its footguns and submit's simplicity
  • Forgetting that supplyAsync's Supplier cannot throw checked exceptions
  • Thinking the two use unrelated thread pools by necessity (both can share a custom executor)

context