skip to content

Async Variants & Executor Choice

Whether a callback runs on the completing thread, the calling thread, the common pool or your executor depends entirely on which variant you called. Interviewers use this to see whether you can reason about where your code actually executes.

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

questions

5

In CompletableFuture, what is the difference between thenApply and thenApplyAsync, and which thread runs the callback in each case?

level: middleimportance: must knowfreq 70%

answer

  1. Non-async = completing thread OR caller
  2. Async = always a fresh pool task
  3. No-arg Async = commonPool()
  4. Async(fn, executor) = your pool
  5. Async to avoid blocking the completing/critical thread

basics

~10 s

thenApply 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 s

Both 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
java
// 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 X

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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.

context

open as a page

Why is running blocking work on no-executor *Async stages (the ForkJoinPool common pool) risky, and what should you do instead?

level: seniorimportance: must knowfreq 62%

basics

~10 s

The no-executor *Async methods use one small shared ForkJoinPool for the whole JVM. Blocking it (I/O, waits) starves every other user of that pool. Pass your own executor sized for blocking work instead.

open as a page

A teammate says 'using thenApply instead of thenApplyAsync means the callback runs synchronously on the thread that created the future.' What is wrong with this claim?

level: middleimportance: should knowfreq 48%

basics

~10 s

It's not the creating thread — it's the completing thread (whichever thread finishes the previous stage), or the caller if the stage was already done. The creating thread is usually irrelevant.

open as a page

What are the common misconceptions about ordering and thread assignment when chaining multiple *Async stages on a CompletableFuture?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Dependent stages still run in order — each waits for the previous result. But *Async does not guarantee the same thread for each stage, and sibling stages attached to the same future can run concurrently in any order.

open as a page

How would you design the executor strategy for a CompletableFuture-based service that mixes CPU-bound transforms and blocking I/O across many stages?

level: principalimportance: should knowfreq 30%

basics

~20 s

Separate the work: keep cheap CPU transforms on non-async or the common pool, and give blocking I/O its own dedicated, bounded executor (or virtual threads). Use one pool per downstream so a slow dependency can't starve the rest.

open as a page