Definition
- Non-async (thenApply) – the callback runs on the thread that completed the previous stage, or on the caller's thread if the CF was already done; no thread switch overhead.
- Async (thenApplyAsync) – the callback is submitted to an Executor (default: ForkJoinPool.commonPool(), or custom); guaranteed thread switch.
- Danger – blocking work in a non-async stage ties up the completing thread, which may be a ForkJoinPool worker, starving all other futures.
- Rule – non-async for cheap transforms (parseInt, multiply, format); async with dedicated executor for blocking or CPU-heavy work.
- Custom pool –
thenApplyAsync(fn, ioPool)runs the callback on a dedicated IO pool, keeping ForkJoinPool free.
At National Bank of India: thenApply is the delivery person who brought the bank statement also calculating the interest right at the door. If it is quick math, that is fine. If it is a five-minute audit, the delivery person is stuck at the door and cannot deliver other statements. thenApplyAsync is having a separate accountant do the calculation, so the delivery person continues delivering immediately.
Understanding the Concepts
How does thenApply choose which thread to run on?
The non-async variant runs the callback on the thread that completed the previous stage. If the CF was already completed by the time thenApply is registered, the callback runs on the current (caller) thread.
package com.bank.service;
import java.util.concurrent.CompletableFuture;
public class ThreadIdentityDemo {
public static void main(String[] args) {
// Case 1: CF completes on pool thread, thenApply runs on same thread
CompletableFuture.supplyAsync(() -> {
System.out.println("supplyAsync: " + Thread.currentThread().getName());
return "SAV2001";
}).thenApply(id -> {
System.out.println("thenApply: " + Thread.currentThread().getName());
return id.toUpperCase();
}).join();
// Case 2: CF already done, thenApply runs on caller thread
CompletableFuture<String> done = CompletableFuture.completedFuture("done");
done.thenApply(s -> {
System.out.println("thenApply on completed: " + Thread.currentThread().getName());
return s.toUpperCase();
}).join();
}
}
supplyAsync: ForkJoinPool.commonPool-worker-1
thenApply: ForkJoinPool.commonPool-worker-1
thenApply on completed: main
Why is blocking in a non-async stage dangerous?
ForkJoinPool.commonPool() has only cores - 1 threads (typically 3 on a 4-core machine). If all threads are blocked in non-async callbacks, no other CompletableFuture can make progress.
// WRONG: blocking IO in non-async callback
for (int i = 0; i < 100; i++) {
CompletableFuture.supplyAsync(() -> fetchAccountId())
.thenApply(id -> {
return callDatabaseBlocking(id); // BLOCKS FJP thread for 200ms!
});
}
// 3 FJP threads x 200ms: only 15 tasks/sec instead of 500/sec!
// 97 tasks queued, waiting for a free thread.
How do you fix it with thenApplyAsync?
ExecutorService ioPool = Executors.newFixedThreadPool(50);
for (int i = 0; i < 100; i++) {
CompletableFuture.supplyAsync(() -> fetchAccountId())
.thenApplyAsync(id -> {
return callDatabaseBlocking(id); // runs on ioPool, not FJP!
}, ioPool);
}
// FJP threads freed immediately, 50 IO threads handle blocking calls.
How do thenApply and thenApplyAsync compare?
| Feature | thenApply | thenApplyAsync |
|---|---|---|
| Thread | Completing thread or caller | Pool thread (FJP or custom) |
| Thread switch | No (zero overhead) | Yes |
| Good for | parseInt, multiply, format | DB call, HTTP, encryption |
| Danger | Blocking starves completer | None (dedicated thread) |
| Custom pool | N/A | Second parameter |
Which methods have async variants?
Every non-async chaining method has an async counterpart:
- thenApply / thenApplyAsync
- thenAccept / thenAcceptAsync
- thenRun / thenRunAsync
- thenCompose / thenComposeAsync
- thenCombine / thenCombineAsync
- handle / handleAsync
- whenComplete / whenCompleteAsync
- exceptionally / exceptionallyAsync (Java 12)
What is the rule for choosing?
- Cheap transform (less than 1ms): thenApply (no overhead).
- Blocking IO (DB, HTTP, file): thenApplyAsync(fn, ioPool).
- CPU-heavy compute (encryption, sorting): thenApplyAsync(fn, cpuPool).
- Never block on ForkJoinPool.commonPool().
What are common mistakes?
// WRONG: blocking DB call in non-async
cf.thenApply(id -> jdbcQuery(id)); // blocks FJP thread
// WRONG: async without custom pool for IO
cf.thenApplyAsync(id -> jdbcQuery(id)); // blocks FJP thread (default pool!)
// CORRECT: async with dedicated IO pool
cf.thenApplyAsync(id -> jdbcQuery(id), ioPool); // blocks on IO pool, FJP free
What are common interview follow-up questions?
- Does thenApplyAsync guarantee a different thread? Yes, it submits to the executor.
- What is the default executor for async methods? ForkJoinPool.commonPool().
- Can the completing thread be the caller's thread? Yes, if the CF was already completed before the callback was registered.
Visual Overview
thenApply (no thread switch) ┌──────────────┐ ┌──────────────┐ │ supplyAsync │────▶│ thenApply │ │ FJP-thread-1 │ │ FJP-thread-1 │ ← same thread! └──────────────┘ └──────────────┘ thenApplyAsync (thread switch) ┌──────────────┐ ┌──────────────┐ │ supplyAsync │────▶│ thenApplyAsync│ │ FJP-thread-1 │ │ ioPool-5 │ ← different thread! └──────────────┘ └──────────────┘ FJP-thread-1 freed immediately
Key Takeaway
The non-async methods (thenApply, thenAccept, thenCombine) run the callback on the thread that completed the previous stage. This is fast for cheap transforms because there is no thread switch overhead. The async variants (thenApplyAsync, thenAcceptAsync) submit the callback to an executor, guaranteeing a thread switch and freeing the completing thread immediately.
The critical rule: never do blocking or heavy work in a non-async stage. ForkJoinPool.commonPool() has only cores - 1 threads. Blocking all of them stalls every CompletableFuture in the JVM. Use thenApplyAsync(fn, dedicatedIoPool) for database calls, HTTP requests, and file operations. Use thenApplyAsync(fn, cpuPool) for encryption and sorting.
At National Bank of India, a credit bureau API call (500ms blocking) was in a thenApply stage. It stalled all ForkJoinPool threads, delaying balance lookups by 500ms. Switching to thenApplyAsync with a dedicated IO pool freed the FJP for other work.
Remember one sentence: use thenApply for cheap transforms, thenApplyAsync with a dedicated pool for anything that blocks.
---