Back to CompletableFuture & Resilience
Q5Question 5 of 19

thenApply vs thenApplyAsync?

Key Concepts
  • thenApply runs the callback on the thread that completed the previous stage (or the caller if already done).
  • thenApplyAsync submits the callback to an Executor, guaranteeing a thread switch.
  • Use thenApply for cheap transforms like parseInt or string formatting (no thread switch overhead).
  • Use thenApplyAsync for blocking or heavy work so you do not tie up the completing thread.
  • Blocking in a non-async stage on ForkJoinPool starves all other CompletableFutures in the JVM.
  • Always pass a dedicated IO pool to thenApplyAsync for blocking calls: thenApplyAsync(fn, ioPool).

Definition

thenApply runs the callback on the completing thread (cheap and fast), while thenApplyAsync runs it on a pool thread (necessary for blocking or heavy work to avoid starving the completing thread).
  • 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.

JAVA
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();
    }
}
TEXT
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.

JAVA
// 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?

JAVA
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?

JAVA
// 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

DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate