CodeGym /Courses /JAVA 25 SELF /Asynchronous tasks: thenApply, thenAccept, thenRun

Asynchronous tasks: thenApply, thenAccept, thenRun

JAVA 25 SELF
Level 55 , Lesson 1
Available

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
thenApply
Yes Yes Transforming the result
thenAccept
Yes No Side effects (printing, logging)
thenRun
No No Just an action after the task completes
thenApplyAsync
Yes Yes Same, but in another thread
thenAcceptAsync
Yes No Same, but in another thread
thenRunAsync
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.

1
Task
JAVA 25 SELF, level 55, lesson 1
Locked
Game World's Golden Reserve: Achievement Tracking
Game World's Golden Reserve: Achievement Tracking
1
Task
JAVA 25 SELF, level 55, lesson 1
Locked
Secret Message Decryption: Reconnaissance Chain
Secret Message Decryption: Reconnaissance Chain
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION