Master Core Java Programming From Scratch

Clear, interactive, and structured coding lessons designed for absolute beginners.

Asynchronous Programming in Java

Learn how Java applications perform work without unnecessarily blocking the calling thread using CompletableFuture, asynchronous tasks, executors, composition, exception handling, and asynchronous pipelines.

What is Asynchronous Programming?

Asynchronous programming allows an application to start an operation and continue doing other work instead of waiting synchronously for that operation to finish.

Java provides several concurrency APIs. For asynchronous workflows, CompletableFuture is one of the most important APIs.

Concept
Start Task
    ↓
Continue Other Work
    ↓
Task Completes
    ↓
Process Result

Synchronous vs Asynchronous

Synchronous Asynchronous
Caller waits for the operation. Caller can continue other work.
Execution is sequential from the caller's perspective. Work may complete later.
Simple for straightforward operations. Useful for composing independent or delayed tasks.

CompletableFuture

CompletableFuture represents a future result that can be completed and composed with other asynchronous operations.

Java
import java.util.concurrent.CompletableFuture;

CompletableFuture<String> future =
    CompletableFuture.supplyAsync(
        () -> "Welcome CIIT Institute👀"
    );

System.out.println(
    future.join()
);

runAsync()

Use runAsync() when the asynchronous operation does not produce a result.

Java
CompletableFuture<Void> future =
    CompletableFuture.runAsync(
        () -> {
            System.out.println(
                "Task is running"
            );
        }
    );

future.join();

supplyAsync()

Use supplyAsync() when the asynchronous operation returns a result.

Java
CompletableFuture<Integer> future =
    CompletableFuture.supplyAsync(
        () -> 10 + 20
    );

System.out.println(
    future.join()
);

thenApply()

thenApply() transforms the result of a completed asynchronous stage.

Java
CompletableFuture<Integer> future =
    CompletableFuture.supplyAsync(
        () -> 10
    );

CompletableFuture<Integer> result =
    future.thenApply(
        value -> value * 2
    );

System.out.println(
    result.join()
);

thenAccept()

thenAccept() consumes the result without producing another result.

Java
CompletableFuture
    .supplyAsync(
        () -> "Core Java"
    )
    .thenAccept(
        value ->
            System.out.println(value)
    )
    .join();

thenRun()

thenRun() executes an action after a previous stage completes but does not receive its result.

Java
CompletableFuture
    .supplyAsync(
        () -> "Task completed"
    )
    .thenRun(
        () ->
            System.out.println(
                "Next action"
            )
    )
    .join();

thenCombine()

thenCombine() combines the results of two independent stages.

Java
CompletableFuture<Integer> first =
    CompletableFuture.supplyAsync(
        () -> 10
    );

CompletableFuture<Integer> second =
    CompletableFuture.supplyAsync(
        () -> 20
    );

CompletableFuture<Integer> result =
    first.thenCombine(
        second,
        (a, b) -> a + b
    );

System.out.println(
    result.join()
);

thenCompose()

thenCompose() is useful when the next asynchronous operation depends on the result of the previous one.

Java
CompletableFuture<String> first =
    CompletableFuture.supplyAsync(
        () -> "User"
    );

CompletableFuture<String> result =
    first.thenCompose(
        value ->
            CompletableFuture.supplyAsync(
                () ->
                    value + " Details"
            )
    );

System.out.println(
    result.join()
);

allOf()

allOf() creates a CompletableFuture that completes when all supplied futures complete.

Java
CompletableFuture<String> one =
    CompletableFuture.supplyAsync(
        () -> "One"
    );

CompletableFuture<String> two =
    CompletableFuture.supplyAsync(
        () -> "Two"
    );

CompletableFuture.allOf(
    one,
    two
).join();

System.out.println(
    one.join()
);

System.out.println(
    two.join()
);

anyOf()

anyOf() completes when any one of the supplied futures completes.

Java
CompletableFuture<String> first =
    CompletableFuture.supplyAsync(
        () -> "First"
    );

CompletableFuture<String> second =
    CompletableFuture.supplyAsync(
        () -> "Second"
    );

Object result =
    CompletableFuture.anyOf(
        first,
        second
    ).join();

System.out.println(result);

exceptionally()

exceptionally() provides a fallback result when the asynchronous computation completes exceptionally.

Java
CompletableFuture<Integer> future =
    CompletableFuture.supplyAsync(
        () -> {
            throw new RuntimeException(
                "Something went wrong"
            );
        }
    )
    .exceptionally(
        error -> 0
    );

System.out.println(
    future.join()
);

handle()

handle() receives both the successful result and possible exception and can transform either case.

Java
CompletableFuture<Integer> future =
    CompletableFuture.supplyAsync(
        () -> 100
    );

CompletableFuture<String> result =
    future.handle(
        (value, error) -> {

            if (error != null) {
                return "Failed";
            }

            return "Value: " + value;
        }
    );

System.out.println(
    result.join()
);

whenComplete()

whenComplete() can be used to observe completion and inspect the result or exception.

Java
CompletableFuture<String> future =
    CompletableFuture.supplyAsync(
        () -> "Success"
    );

future.whenComplete(
    (value, error) -> {

        if (error != null) {

            System.out.println(
                "Error: " + error
            );

        } else {

            System.out.println(
                "Result: " + value
            );

        }

    }
).join();

Async Variants

Many CompletableFuture methods have an Async variant, such as thenApplyAsync() and thenAcceptAsync().

Java
CompletableFuture<Integer> future =
    CompletableFuture.supplyAsync(
        () -> 10
    );

CompletableFuture<Integer> result =
    future.thenApplyAsync(
        value -> value * 5
    );

System.out.println(
    result.join()
);

Using an Executor

An explicit Executor can be supplied when you need control over the execution environment.

Java
ExecutorService executor =
    Executors.newFixedThreadPool(4);

CompletableFuture<String> future =
    CompletableFuture.supplyAsync(
        () -> "Running in executor",
        executor
    );

System.out.println(
    future.join()
);

executor.shutdown();

Delayed Completion

Modern Java provides APIs such as CompletableFuture.delayedExecutor() for delayed execution scenarios.

Java
Executor delayed =
    CompletableFuture.delayedExecutor(
        2,
        TimeUnit.SECONDS
    );

CompletableFuture<String> future =
    CompletableFuture.supplyAsync(
        () -> "Completed later",
        delayed
    );

System.out.println(
    future.join()
);

Building an Asynchronous Pipeline

Java
CompletableFuture<String> result =
    CompletableFuture.supplyAsync(
        () -> "java"
    )
    .thenApply(
        String::toUpperCase
    )
    .thenApply(
        value ->
            "Language: " + value
    );

System.out.println(
    result.join()
);

Non-Blocking Composition

Asynchronous APIs are most useful when operations are composed instead of repeatedly blocking the current thread waiting for every intermediate result.

Java
CompletableFuture<String> user =
    getUserAsync();

CompletableFuture<String> result =
    user.thenCompose(
        this::getUserDetailsAsync
    );

result.thenAccept(
    System.out::println
);

join() vs get()

Method Behavior
join() Waits for completion and wraps failures in an unchecked CompletionException.
get() Waits for completion and uses checked exceptions such as ExecutionException.

Exception Handling in Async Code

Exceptions occurring during asynchronous computation are represented in the completion stage and can be handled using methods such as exceptionally(), handle(), and whenComplete().

Java
CompletableFuture<String> future =
    CompletableFuture.supplyAsync(
        () -> {

            if (true) {
                throw new RuntimeException(
                    "Database error"
                );
            }

            return "Success";
        }
    )
    .exceptionally(
        error -> "Fallback"
    );

System.out.println(
    future.join()
);

Real-World Example

Consider an application that retrieves a user and then loads that user's orders.

Java
CompletableFuture<User> userFuture =
    getUserAsync(101);

CompletableFuture<List<Order>> ordersFuture =
    userFuture.thenCompose(
        user ->
            getOrdersAsync(
                user.getId()
            )
    );

ordersFuture.thenAccept(
    orders ->
        System.out.println(
            "Orders: " + orders.size()
        )
);

Asynchronous Programming Best Practices

  • Prefer composition over unnecessary blocking.
  • Use thenApply() for synchronous transformation of a stage result.
  • Use thenCompose() when the next operation itself returns a CompletableFuture.
  • Use thenCombine() for independent operations whose results need to be combined.
  • Handle asynchronous failures explicitly.
  • Use a suitable Executor when the default execution model is not appropriate.
  • Avoid blocking operations inside shared or latency-sensitive executor threads.

Common Mistakes

  • Calling join() or get() immediately after starting every task, which can remove much of the benefit of asynchronous composition.
  • Ignoring exceptions from asynchronous operations.
  • Using an inappropriate executor for blocking work.
  • Sharing mutable state between asynchronous tasks without proper synchronization.

Interview Questions

Asynchronous programming allows work to be started without requiring the caller to wait synchronously for that work to finish.

CompletableFuture represents a future result and provides APIs for composing asynchronous stages, handling results, and handling failures.

runAsync() performs an asynchronous action without returning a result, while supplyAsync() returns a result.

thenCompose() chains dependent asynchronous operations and avoids creating a nested CompletableFuture.

thenCombine() combines the results of two independent CompletionStage computations.

Common methods include exceptionally(), handle(), and whenComplete().

Both wait for completion. get() uses checked exceptions, while join() reports failures using unchecked completion exceptions.

allOf() creates a completion stage that completes when all supplied futures have completed.
Summary

Java asynchronous programming provides APIs for starting, composing, combining, and handling asynchronous tasks. CompletableFuture is central to this model, with methods such as runAsync, supplyAsync, thenApply, thenCompose, thenCombine, allOf, anyOf, exceptionally, handle, and whenComplete.