skip to content

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%

answer

  1. Async picks the THREAD, not the ORDER
  2. Linear chain is sequential regardless of suffix
  3. thenApply may run inline on completer/caller thread
  4. Use Async to get off event-loop/UI/blocking threads
  5. Parallelism = independent futures + thenCombine/allOf

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.

solid answer

~50 s

Dependent stages in a CompletableFuture chain always run in their declared order: thenApply(b) after thenApply(a), regardless of the Async suffix, because each stage's input is the previous stage's output. The only thing the *Async variant changes is the *execution thread*: thenApply runs the callback on the thread that completed the predecessor (or the caller, inline); thenApplyAsync resubmits it to an executor (common pool or one you provide), so it may run on a different thread and after a scheduling hop. The misconception is that 'Async' makes things parallel or lets later stages overtake earlier ones — it doesn't; a linear chain is sequential either way. Async matters for *thread ownership*: to get off a completing thread (e.g. a Netty event-loop you mustn't block), to move blocking work to a dedicated pool, or to avoid deep synchronous recursion. True parallelism comes from *independent* futures (multiple supplyAsync) combined with thenCombine/allOf, not from the Async suffix on a single chain.

code

java · 8 lines
java
// MISCONCEPTION: 'Async everywhere' does NOT parallelize a dependent chain
cf.thenApplyAsync(this::step1)   // runs first, on some pool thread
  .thenApplyAsync(this::step2);  // still waits for step1; just a thread hop

// REAL parallelism: independent futures, then combine
var a = CompletableFuture.supplyAsync(this::loadA, pool);
var b = CompletableFuture.supplyAsync(this::loadB, pool); // runs concurrently with a
String merged = a.thenCombine(b, this::merge).join();

go deeper

for a junior

Can state that Async means the callback runs via an executor, and that a chain runs in order; may not articulate the inline-execution subtlety.

for a middle

Explains that Async controls the thread not the order, and knows parallelism needs independent futures combined with thenCombine/allOf.

for a senior

Reasons about inline-on-completer/caller execution, why you'd async-hop off an event-loop or to a dedicated pool, stack-depth concerns, and clearly separates thread-choice from ordering and from parallelism.

for a principal

Sets conventions for executor selection across async-heavy services (e.g. never run callbacks on event-loop threads, always pass an explicit executor for blocking stages) and educates teams on the ordering-vs-threading distinction to prevent latency and correctness bugs.

## Setup: what 'dependent stage' means When you write `cf.thenApply(f).thenApply(g)`, you create a *linear dependency chain*: `g`'s input is `f`'s output, and `f`'s input is `cf`'s value. By definition, `g` cannot start until `f` has produced a result. This ordering is **inherent to the data dependency** and has nothing to do with which method overload you used. ## What the `Async` suffix actually controls Each transformation comes in three forms: - **`thenApply(fn)`** — runs `fn` on **whatever thread completed the predecessor** (or, if the predecessor is already complete when you attach, possibly the *calling* thread, inline/synchronously). - **`thenApplyAsync(fn)`** — submits `fn` to the **default executor** (`ForkJoinPool.commonPool()`). - **`thenApplyAsync(fn, executor)`** — submits `fn` to **your** executor. So the suffix chooses the **execution thread / scheduling**, not the **order**. `g` still runs after `f` in all three cases. ## The misconception, stated and refuted > "If I use `thenApplyAsync` everywhere, the stages run in parallel / a later stage might finish first / order becomes non-deterministic." False, for a *linear chain*. A stage that depends on a prior stage's value is gated on that value. `thenApplyAsync` only means "when the predecessor completes, *resubmit* my callback to an executor (a scheduling hop, possibly a different thread) rather than running it on the completing thread." The *happens-before* order f → g is preserved. There's no overtaking. What *is* non-deterministic is **which thread** runs each stage and the **tiny scheduling latency** of the hop — not the *order of effects*. ## Where async genuinely matters 1. **Getting off a special thread.** If the predecessor completes on a thread you must not occupy — e.g. a Netty/event-loop thread, a UI thread, or a thread that will block — `thenApply` would run your callback *on that thread*. `thenApplyAsync(fn, pool)` moves it off. This is the real, important reason to choose Async. 2. **Moving blocking work to the right pool.** Pair Async with a *dedicated* executor so blocking callbacks don't run on a completing thread you care about (or the common pool). 3. **Avoiding deep synchronous recursion / stack depth.** Long non-async chains that complete inline can build deep stacks; the async hop breaks the recursion. 4. **Thread-confinement requirements.** Some libraries demand callbacks run on a specific executor. ## Where parallelism *actually* comes from Parallelism is about **independent** computations, not the Async suffix. To run things concurrently you create *separate* futures and combine them: ```java var a = CompletableFuture.supplyAsync(this::loadA, pool); var b = CompletableFuture.supplyAsync(this::loadB, pool); var combined = a.thenCombine(b, this::merge); // a and b ran concurrently ``` Here `loadA` and `loadB` have no data dependency, so they execute in parallel; `thenCombine` waits for both. `allOf(...)` generalizes this to many. A single `a.thenApplyAsync(...).thenApplyAsync(...)` chain is still sequential. ## Subtle related point: non-async inline execution Because `thenApply` can run *inline on the completing thread or even the calling thread*, two pitfalls follow: (a) heavy/blocking work in a non-async stage may execute on a thread you didn't expect (e.g. the one that called `complete()`); (b) attaching a non-async stage to an *already-completed* future runs it immediately on the current thread, which can surprise you. Choosing Async with an explicit executor removes this ambiguity. ## Mental model `Async` = "hand this callback to an executor instead of running it on the completing thread." It answers *where* (which thread), never *when relative to its siblings in a chain* (the data dependency fixes that). Want parallelism? Make the futures independent and combine them.

  • On which thread does a non-async thenApply run if the predecessor is already complete when you attach it?
    It typically runs synchronously on the *calling* thread — the one attaching the stage — because there's a completed value to apply immediately. This is a common surprise and a reason to use thenApplyAsync with an explicit executor when you need control.
  • How do you actually run two independent calls in parallel and combine them?
    Create two separate futures (e.g. two supplyAsync calls on a pool) so they have no data dependency and run concurrently, then join them with thenCombine (two results) or allOf (many). The Async suffix on a single dependent chain does not create this parallelism.

saying these in an interview costs you the question

  • Claiming thenApplyAsync makes a linear chain run in parallel
  • Thinking later async stages can finish before earlier ones in the same chain
  • Believing thenApply always runs on a background pool thread
  • Assuming Async randomizes execution order rather than just the thread

context