Question 11 · Section 19

What is the difference between thenApply() and thenApplyAsync()

Both methods transform the result of a CompletableFuture, but in different threads:

Language versions: English Russian Ukrainian

🟢 Junior Level

Both methods transform the result of a CompletableFuture, but in different threads:

  • thenApply() — executes in the same thread that completed the previous CF
  • thenApplyAsync() — executes in a different thread (ForkJoinPool or specified Executor)
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
    System.out.println("Supply: " + Thread.currentThread().getName());
    return "Hello";
});

// thenApply — same thread
cf.thenApply(s -> {
    System.out.println("thenApply: " + Thread.currentThread().getName());
    return s + " World";
});

// thenApplyAsync — different thread (ForkJoinPool)
cf.thenApplyAsync(s -> {
    System.out.println("thenApplyAsync: " + Thread.currentThread().getName());
    return s + " World";
});

🟡 Middle Level

When to use which

thenApply — for lightweight operations:

// Lightweight transformation — no point switching threads
cf.thenApply(s -> s.toUpperCase())
  .thenApply(s -> s.trim())
  .thenAccept(System.out::println);

thenApplyAsync — for heavy operations:

// Heavy processing — better in another thread
cf.thenApplyAsync(s -> heavyProcessing(s), executor)
  .thenAccept(System.out::println);

With custom Executor:

ExecutorService executor = Executors.newFixedThreadPool(10);

cf.thenApplyAsync(s -> transform(s), executor);

Typical mistakes

  1. thenApplyAsync without Executor: ```java // Uses ForkJoinPool.commonPool() cf.thenApplyAsync(s -> s.toUpperCase());

// ⚠️ commonPool has a limited number of threads // For I/O operations — bad

// ✅ Custom Executor cf.thenApplyAsync(s -> s.toUpperCase(), ioExecutor);


---

## 🔴 Senior Level

### Internal Implementation

**thenApply:**
```java
public <U> CompletableFuture<U> thenApply(Function<? super T,? extends U> fn) {
    return uniApplyStage(null, fn);
}

// Executes in the thread that completed the previous CF
// No thread switch — minimal overhead

thenApplyAsync:

public <U> CompletableFuture<U> thenApplyAsync(Function<? super T,? extends U> fn) {
    return uniApplyStage(asyncPool, fn);  // asyncPool = ForkJoinPool.commonPool()
}

public <U> CompletableFuture<U> thenApplyAsync(
    Function<? super T,? extends U> fn, Executor executor
) {
    return uniApplyStage(screenExecutor(executor), fn);
}

// asyncPool — thread from ForkJoinPool.commonPool()
// or the specified executor

Architectural Trade-offs

thenApply thenApplyAsync
Same thread Different thread
~5 ns overhead ~1μs overhead
For lightweight operations For heavy/blocking
No context switch Thread switch

Edge Cases

1. ThreadLocal:

// thenApply — ThreadLocal is preserved
ThreadLocal<String> context = new ThreadLocal<>();
context.set("value");

cf.thenApply(s -> {
    String ctx = context.get();  // OK — same thread
    return s + ctx;
});

// thenApplyAsync — ThreadLocal is lost
cf.thenApplyAsync(s -> {
    String ctx = context.get();  // null! — different thread
    return s;
});

2. Blocking operations:

// ❌ thenApply with a blocking operation
cf.thenApply(s -> {
    Thread.sleep(1000);  // blocks the ForkJoinPool thread!
    return s;
});

// ✅ thenApplyAsync with a separate Executor
cf.thenApplyAsync(s -> {
    Thread.sleep(1000);
    return s;
}, ioExecutor);

Performance

thenApply:
- Overhead: ~5 ns
- No thread switch

thenApplyAsync (commonPool):
- Overhead: ~1μs
- Thread switch + queue

thenApplyAsync (custom executor):
- Overhead: ~2-5μs
- Thread switch + queue

For lightweight operations: thenApply is significantly faster than thenApplyAsync
(no scheduling overhead), but exact ratios depend on JVM, load, and hardware.

When NOT to use thenApplyAsync

  • ThreadLocal context — Async may switch threads, ThreadLocal is lost
  • Trivial transformations (getLength, toString) — scheduling overhead > benefit

Production Experience

Pipeline:

// thenApply for lightweight transformations
fetchDataAsync()
    .thenApply(data -> parseJson(data))       // lightweight
    .thenApply(obj -> validate(obj))          // lightweight
    .thenApplyAsync(valid -> transform(valid), transformExecutor)  // heavy
    .thenAcceptAsync(result -> save(result), ioExecutor);  // I/O

Best Practices

// ✅ thenApply for lightweight operations
cf.thenApply(s -> s.toUpperCase());

// ✅ thenApplyAsync for heavy/blocking
cf.thenApplyAsync(s -> heavyProcessing(s), executor);

// ✅ Custom Executor for I/O
cf.thenApplyAsync(s -> ioOperation(s), ioExecutor);

// ❌ thenApplyAsync without reason (overhead)
// ❌ thenApply with blocking operations
// ❌ commonPool for I/O

🎯 Interview Cheat Sheet

Must know:

  • thenApply — in the same thread as the previous CF, ~5 ns overhead
  • thenApplyAsync — in ForkJoinPool.commonPool() or specified Executor, ~1μs overhead
  • thenApply for lightweight CPU transformations, thenApplyAsync for heavy/blocking
  • ThreadLocal is lost with Async — different thread
  • Without Executor, thenApplyAsync uses ForkJoinPool.commonPool()

Frequent follow-up questions:

  • When thenApply, when thenApplyAsync? — thenApply for lightweight (toUpperCase), thenApplyAsync for heavy (heavyProcessing, I/O)
  • thenApplyAsync without Executor — which pool? — ForkJoinPool.commonPool(), for I/O this is thread pool starvation
  • Why is thenApply faster? — No thread switch, queue, or scheduling overhead
  • ThreadLocal with thenApplyAsync? — Will be lost, since it’s a different thread. Need explicit context passing

Red flags (DO NOT say):

  • “thenApplyAsync is always better — it’s async” — +~1μs overhead, excessive for lightweight operations
  • “thenApply blocks the thread” — it executes in the thread that completed the CF, no additional blocking
  • “thenApplyAsync without Executor is safe for I/O” — uses commonPool, thread pool starvation

Related topics:

  • [[12. What thread pool is used by default for async methods]]
  • [[13. How to specify your own Executor for CompletableFuture]]
  • [[4. What is the difference between thenApply() and thenCompose()]]
  • [[15. Why is it important to avoid blocking operations in CompletableFuture]]