In CompletableFuture, what is the difference between thenApply and thenApplyAsync, and which thread runs the callback in each case?
answer
- Non-async = completing thread OR caller
- Async = always a fresh pool task
- No-arg Async = commonPool()
- Async(fn, executor) = your pool
- Async to avoid blocking the completing/critical thread
basics
~10 sthenApply may run the callback on whatever thread completed the previous stage (or the caller). thenApplyAsync hands the callback to a separate thread pool to run, so it does not block the completing thread.
solid answer
~40 sBoth attach a function to transform a stage's result. The difference is which thread runs that function. thenApply is non-async: the callback runs on the thread that completed the previous stage, or, if the stage is already complete when you attach, on the calling thread. So it piggybacks on an existing thread and runs inline. thenApplyAsync is async: the callback is submitted as a separate task to an executor. With no executor argument it uses the ForkJoinPool.commonPool(); with an executor argument it runs there. Use Async when the callback is slow or blocking, so you do not tie up the completing thread (which might be a critical pool thread or even the main thread). Use the non-async form for cheap, fast transformations to avoid extra scheduling overhead and thread hand-off.
code
java · 15 lines// Already-complete upstream: thenApply runs on the CALLER thread (synchronously)
String here = Thread.currentThread().getName();
CompletableFuture.completedFuture("x")
.thenApply(v -> {
assert Thread.currentThread().getName().equals(here); // same thread
return v.toUpperCase();
});
// Incomplete upstream: thenApply runs on the COMPLETING (pool worker) thread
CompletableFuture.supplyAsync(() -> compute()) // pool worker W
.thenApply(v -> transform(v)); // also runs on W
// *Async: submitted as a fresh task -> may run on a DIFFERENT pool thread
CompletableFuture.supplyAsync(() -> compute()) // worker W
.thenApplyAsync(v -> transform(v)); // common pool, maybe worker Xgo deeper
Knows thenApply transforms a result and thenApplyAsync runs it 'on another thread'; can use both but may not know which exact thread runs the non-async callback.
Explains that non-async runs on the completing thread or the caller, while *Async submits a task to a pool; knows the no-arg Async uses the common pool.
Reasons about the already-completed-future case (runs on caller), picks Async to keep blocking work off critical pool threads, and weighs the scheduling overhead trade-off.
Designs pipeline thread-assignment policy across a service — which stages get dedicated executors, how to keep the common pool free of blocking work, and documents the threading contract for the team.
## Setup: what a CompletableFuture stage is A `CompletableFuture<T>` represents a value of type `T` that may not be ready yet. You build a **pipeline** of dependent stages: each stage takes the result of the previous one and produces a new value. `thenApply(fn)` is one such dependent stage — it takes the previous result, applies the function `fn`, and the new stage holds the function's return value. The central question for every dependent stage is: **on which thread does my callback `fn` run?** Java gives you two flavors of (almost) every operator: a plain one (`thenApply`) and an `Async` one (`thenApplyAsync`). ## The non-async form: `thenApply` `thenApply` does **not** schedule the callback onto a thread pool. Instead it runs `fn` **inline** on whichever thread happens to be available at the moment the dependency is satisfied. There are two cases: 1. **The previous stage is NOT yet complete when you attach the callback.** Then `fn` will run later, on the **thread that completes the previous stage**. For example, if stage A is doing work on a worker thread and you attach `thenApply` to it, when A finishes, that same worker thread immediately runs your `fn` before moving on. 2. **The previous stage is ALREADY complete when you attach the callback** (e.g. you call `CompletableFuture.completedFuture(x).thenApply(fn)`). Then there is no completion event to wait for, so `fn` runs **synchronously on the calling thread**, right inside the `thenApply(...)` call. Key takeaway: with the non-async form you **cannot reliably predict** which thread runs the callback. It is either the completing thread or the caller. This is fine for cheap transformations but dangerous for slow or blocking work, because you might accidentally run a blocking operation on a thread you should not block. ## The async form: `thenApplyAsync` `thenApplyAsync` always **submits the callback as a fresh task to an executor** (a thread pool). It never runs inline on the caller, and it never piggybacks on the completing thread — it schedules a new task. There are two overloads: - `thenApplyAsync(fn)` — no executor. The task runs on the **default async executor**, which is `ForkJoinPool.commonPool()` (a JVM-wide shared pool) — unless the common pool has fewer than 2 parallelism, in which case the JVM falls back to a new thread per task. - `thenApplyAsync(fn, executor)` — runs the task on the **executor you pass in**. Because it always hands the work to a pool, `*Async` decouples your callback from the completing thread. That is exactly what you want when the callback is slow, blocking (I/O, JDBC, a network call), or otherwise should not run on the previous stage's thread. ## Why this matters in practice - **Don't block the completing thread.** If stage A completes on a Netty event-loop thread (or any shared pool thread), and you attach a blocking `thenApply`, you block that critical thread. Using `thenApplyAsync(fn, myBlockingPool)` moves the blocking work off it. - **Don't accidentally run on the main thread.** If the upstream future was already complete, `thenApply` runs on whatever thread called it — often your main/request thread. People are surprised when their 'async' code runs synchronously. - **Scheduling has a cost.** Each `*Async` hand-off is a task submission plus a context switch. For a one-line `x -> x + 1` transform, that overhead can dwarf the work. So `*Async` is not 'better' — it is a tool for moving work off the wrong thread. ## Mental model Think of `thenApply` as 'run it wherever we happen to be standing' and `thenApplyAsync` as 'hand it to the pool and let a pool thread pick it up.' The same plain-vs-Async pair exists for `thenAccept`/`thenAcceptAsync`, `thenRun`/`thenRunAsync`, `thenCompose`/`thenComposeAsync`, `thenCombine`/`thenCombineAsync`, `handle`/`handleAsync`, `whenComplete`/`whenCompleteAsync`, and so on — the rule is identical for all of them.
- If the upstream future is already completed when you call thenApply, which thread runs the callback?The calling thread — it runs synchronously, inline in the thenApply(...) call, because there is no future completion event to attach to.
- Does the same async-vs-non-async distinction apply to thenAccept, thenCompose, handle, etc.?Yes. Every dependent operator has a plain and an *Async overload with identical thread-assignment semantics; thenApply is just the canonical example.
saying these in an interview costs you the question
- Claiming thenApply 'always runs on a separate/background thread' — it does not; it runs inline on the completing thread or the caller.
- Claiming thenApplyAsync 'is just faster' — it adds scheduling overhead and is about WHERE the callback runs, not speed.
- Assuming thenApply always runs on the same thread that created the future — it runs on the completing thread, which may be different.