1. Launching an asynchronous task: supplyAsync and runAsync
The most common way to start an asynchronous task is to use CompletableFuture.supplyAsync. This method takes a lambda or a method that returns a result. For example, we want to simulate loading data from a server:
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// Simulating a long operation (for example, a file download)
sleep(1000);
return "Data from server";
});
System.out.println("Task started!");
// ... you can do something else while the task is running
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException ignored) {}
}
}
runAsync: when you don’t need a result
If your task returns nothing (for example, it just writes to a log or sends a notification), use runAsync:
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
sleep(500);
System.out.println("Operation completed!");
});
runAsync always returns CompletableFuture<Void>, because no result is expected.
2. thenApply, thenAccept, thenRun: what’s the difference?
When an asynchronous task completes, you usually want to do something with the result. For this purpose there are “handler” methods:
- thenApply — transforms the result and returns a new result.
- thenAccept — consumes the result and returns nothing (used for side effects).
- thenRun — neither takes the result nor returns anything (just runs an action after the task completes).
thenApply: processing and transforming the result
If you need to transform the result of the previous task, use thenApply. For example, we loaded a string and now want to know its length:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Java");
CompletableFuture<Integer> lengthFuture = future.thenApply(s -> {
System.out.println("Computing string length...");
return s.length();
});
// lengthFuture now holds an Integer — the length of the string "Java"
lengthFuture.thenAccept(len -> System.out.println("Length: " + len));
What happens:
- future holds the string "Java".
- thenApply transforms the string into its length (int).
- thenAccept prints the result.
thenAccept: act on the result (returns nothing)
If you just need to do something with the result (for example, print it) and don’t need to return anything, use thenAccept:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello, world!");
future.thenAccept(result -> {
System.out.println("Result: " + result);
});
thenAccept is like a consumer: it consumes the result and does something useful with it.
thenRun: an action without a result
If you want to simply perform some action after the task completes, and you don’t need the result, use thenRun:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Done!");
future.thenRun(() -> {
System.out.println("Loading finished!");
});
Note: inside thenRun you cannot access the result of the previous task — it’s ignored.
3. Chaining calls: building a pipeline of tasks
The biggest strength of CompletableFuture is the ability to build chains of computations. Each method (thenApply, thenAccept, thenRun) returns a new CompletableFuture, to which you can add another handler.
Example: multistep processing
Let’s refine our app: load data, transform it, print the result, and write to the log that everything is finished.
CompletableFuture.supplyAsync(() -> {
System.out.println("Step 1: Loading data...");
sleep(500);
return "Java";
})
.thenApply(data -> {
System.out.println("Step 2: Transforming data...");
return data.toUpperCase();
})
.thenAccept(result -> {
System.out.println("Step 3: Printing result: " + result);
})
.thenRun(() -> {
System.out.println("Step 4: All done!");
});
Console output:
Step 1: Loading data...
Step 2: Transforming data...
Step 3: Printing result: JAVA
Step 4: All done!
Note:
Each next step starts only after the previous one completes. This allows you to build real data-processing “pipelines”.
4. Asynchronous variants: thenApplyAsync, thenAcceptAsync, thenRunAsync
By default, handlers (thenApply, thenAccept, thenRun) run in the same thread in which the previous task completed. Sometimes that’s not ideal — if the processing is heavy, it’s better to offload it to a separate thread.
For that, there are async versions:
- thenApplyAsync
- thenAcceptAsync
- thenRunAsync
What’s the difference?
- Without Async: the handler may run in the same thread as the previous task (for example, if the task finished in the ForkJoinPool, the handler runs there as well).
- With Async: the handler is guaranteed to run in another thread from the ForkJoinPool (or your Executor).
Example: compare a regular and an async handler
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
System.out.println("Loading... [" + Thread.currentThread().getName() + "]");
return "Hello";
});
future.thenApply(result -> {
System.out.println("thenApply: [" + Thread.currentThread().getName() + "]");
return result + " World";
});
future.thenApplyAsync(result -> {
System.out.println("thenApplyAsync: [" + Thread.currentThread().getName() + "]");
return result + " Async World";
});
Typical output:
Loading... [ForkJoinPool.commonPool-worker-1]
thenApply: [ForkJoinPool.commonPool-worker-1]
thenApplyAsync: [ForkJoinPool.commonPool-worker-2]
Conclusion:
The async handler runs in a different thread.
When to use Async methods?
- If the processing is resource-intensive (e.g., heavy computation, network I/O).
- If you don’t want to block the thread in which the previous task finished.
- If you want to manage threads explicitly (for example, pass your Executor as the second argument).
5. Useful details
Table: comparing thenApply, thenAccept, thenRun
| Method | Uses the result? | Returns a value? | What to use it for |
|---|---|---|---|
|
Yes | Yes | Transforming the result |
|
Yes | No | Side effects (printing, logging) |
|
No | No | Just an action after the task completes |
|
Yes | Yes | Same, but in another thread |
|
Yes | No | Same, but in another thread |
|
No | No | Same, but in another thread |
Question: how to build long chains?
You can call methods one after another, like LEGO bricks:
CompletableFuture.supplyAsync(() -> "42")
.thenApply(Integer::parseInt)
.thenApply(x -> x * 2)
.thenAccept(x -> System.out.println("Result: " + x));
Output:
Result: 84
Each next step receives the result of the previous one, can modify it, or just use it.
6. Common mistakes when working with thenApply, thenAccept, thenRun
Error #1: Mixing up return types.
thenApply must return a value that goes further down the chain. If you accidentally use thenApply but don’t return a result, the next operation will get null (or it won’t compile at all). For side effects, use thenAccept or thenRun.
Error #2: Trying to use the result in thenRun.
Inside thenRun there’s no access to the previous task’s result. If you want to use the result, choose thenApply or thenAccept.
Error #3: Blocking the main thread.
If you call get() or join() on the main thread, you lose all the benefits of asynchrony: the thread will wait for the task to complete, just like in good old synchronous code. It’s better to use non-blocking chains and callbacks.
Error #4: Not handling errors.
If an exception occurs in the chain and you didn’t add a handler (exceptionally, handle, whenComplete), it will be “lost”, and the task may complete with an error you won’t see. Always handle errors in chains.
Error #5: Unexpected execution on a different thread.
Async methods (thenApplyAsync and others) may run on a different thread. If you access variables that aren’t thread-safe, data races may occur.
GO TO FULL VERSION