Mastering Asynchronous Programming in Java

Modern applications must handle thousands (or even millions) of requests simultaneously and maintain high performance and responsiveness. Traditional synchronous programming often becomes a bottleneck because tasks execute sequentially and block system resources.

Even the thread pool approach has its limitations, since we cannot create millions of threads and still achieve fast task switching.

This is where asynchronous programming comes into play. In this guide, you’ll learn the fundamentals of asynchronous programming in Java, explore basic concurrency concepts, and dive deep into CompletableFuture, one of the most powerful tools used in Java application development services.

What Is Asynchronous Programming?

Asynchronous programming is a type of programming that lets your code run other tasks without having to wait for the main part to finish, so the program keeps working even when it’s waiting for other operations.

Asynchronous systems don’t have to perform tasks one after another, finishing each before moving to the next; but can, for example, initiate a task and leave it to continue working on other ones, at the same time, handling different results as they become available.

It’s an excellent method if the operation demands a lot of waiting, for instance, database queries, network or API calls, file input/output (I/O) operations, and other types of background computations.

Technically, this means a multiplexing and continuation scheme: each time a specific operation requires I/O completion, the corresponding task frees the processor for other tasks. Once the I/O operations are completed and multiplexed, the deferred task continues execution.

Synchronous vs Asynchronous Execution

In order to completely perceive the concept of asynchronous programming, it is important to understand the concept of synchronous execution first. Both define how tasks are processed and how programs handle waiting operations.

Programming in Java

Synchronous Execution

In synchronous programming, tasks are carried out sequentially one after another. One operation has to be finished before the next one can be started. Tasks can be parallelized across different threads, but this means we will have to do it manually, additionally performing synchronization between threads.

If a task needs time to be completed, for example, making a database query or getting a response from an API, the program will have to stop and wait for that task to finish. The thread running the task will remain blocked during this waiting time.

What’s worse, if we need data from multiple sources at the same time (for example, from an API and from a database), we’ll wait for them one by one.

Example scenario: Request -> Database Query -> Waiting -> Process Result -> Return Response

The system (or at least thread from thread pool) gets stuck until the database operation is done.

Asynchronous Execution

In asynchronous programming, tasks are executed independently without blocking the main execution flow. Instead of waiting for a task to complete, the program continues executing other operations.

In practice, this means we have a way to increase throughput. For example, if we have multiple requests at once, we can process them in parallel. A single request won’t be processed faster, but a couple of requests will be sufficient, and the difference can be significant.

When the asynchronous task finishes, its result is handled through callbacks, futures, or completion handlers.

Example workflow:

Request -> Start Database Query -> Continue Processing -> Receive Result -> Handle Result

This approach allows applications to handle more work concurrently.

Feature Synchronous Execution Asynchronous Execution
Task flow Sequential Concurrent
Thread behavior Blocking Non-blocking
Performance Slower for I/O tasks Faster for I/O tasks
Complexity Simpler More complex

Key Differences Between Synchronous Execution & Asynchronous Execution

Benefits of Asynchronous Programming

Asynchronous programming offers a range of advantages that make applications faster, more efficient, and more responsive.

The first advantage is increased performance. In traditional synchronous programming, a program often has to wait for the completion of database queries, file access operations, or API calls.

During this time, the program is unable to proceed with executing other tasks. Asynchronous programming helps avoid such delays: an application can initiate a task and continue performing other work while waiting for the result. Another advantage is more efficient resource utilization.

When a thread becomes blocked while waiting for an operation to complete, system resources, such as CPU time, are wasted. Asynchronous programming allows threads to switch to executing other tasks instead of sitting idle, thereby contributing to more efficient application performance.

Furthermore, asynchronous programming enhances application scalability. Since tasks can be executed in parallel, the system is capable of handling multiple requests simultaneously, a capability that is particularly crucial for web servers, cloud services, and applications designed to support a large number of users in real time.

Core Concepts Behind Asynchronous Programming in Java

Before diving into advanced tools like CompletableFuture, it’s impёёortant to understand the core building blocks.

Mastering Asynchronous

Threads and Multithreading

A thread represents a single path of execution in a program. Java allows multiple threads to run at the same time, enabling concurrent task execution.

Example:

Thread thread = new Thread(() -> {
    System.out.println("Task running asynchronously");
});
thread.start();

While threads enable concurrency, managing them manually can be complex, especially in large applications, because creating too many threads can affect performance.

Executor Framework

To simplify thread management, Java provides the Executor Framework, which allows tasks to be executed using thread pools. A thread pool reuses existing threads instead of creating new ones for every task, improving efficiency and reducing overhead.

Example:

ExecutorService executor = Executors.newFixedThreadPool(5);

executor.submit(() -> {

System.out.println("Task executed asynchronously");

});

executor.shutdown();

Using executors makes it easier to control concurrency, limit the number of active threads, and optimize performance.

Futures

A Future represents the result of an asynchronous computation that will be available later.

Example:

Future<Integer> future = executor.submit(() -> 10 + 20);

Integer result = future.get(); // blocks until result is ready

While Futures allow basic asynchronous handling, they have limitations:

  • Calling get() blocks the thread until the result is ready.
  • They cannot be easily chained for dependent tasks.
  • Error handling is limited.

These limitations led to the creation of CompletableFuture, which provides a more flexible and powerful way to manage asynchronous workflows in Java.

Introduction to CompletableFuture

CompletableFuture is a great tool introduced in Java 8 as a component of the java.util.concurrent package.

It makes asynchronous programming less complicated by providing the developers with a way to execute tasks in the background, link operations, deal with results, and also handle errors, all of these without interrupting the main thread.

Synchronous vs Asynchronous Execution

In contrast to the basic Future interface, which only permits blocking calls for retrieving results, CompletableFuture offers non-blocking, functional-style workflows. This feature makes it a perfect solution for the development of contemporary, scalable applications that involve several asynchronous operations.

Feature Future CompletableFuture
Non-blocking callbacks No Yes
Task chaining No Yes
Combining multiple tasks No Yes
Exception handling Limited Advanced

CompletableFuture vs Future

Creating Asynchronous Tasks

Once you get CompletableFuture, it is time to explore how asynchronous tasks can be created in Java. As you probably know, CompletableFuture has very simple methods to carry out tasks in the background so that the main thread is not blocked.

Among the methods that are most frequently used for this purpose are runAsync() and supplyAsync(), and there is also the possibility to employ custom executors to have even greater control over thread management.

Using runAsync()

The runAsync() method is used to execute a task asynchronously when no result is needed. It runs the task in a separate thread and immediately returns a CompletableFuture<Void>.

Example:

CompletableFuture future = CompletableFuture.runAsync(() -> {
System.out.println("Task running asynchronously");
});

Here, the task executes in the background, and the main thread continues without waiting for it to finish.

Using supplyAsync()

If you need a result from the asynchronous task, use supplyAsync(). This method returns a CompletableFuture<T>, where T is the type of the result.

Example:

CompletableFuture future = CompletableFuture.supplyAsync(() -> {
return 5 * 10;
});
// Retrieve the result (blocking only here)
Integer result = future.join();
System.out.println(result); // Output: 50

supplyAsync() allows you to execute computations asynchronously and get the result once it’s ready, without blocking the main thread until you explicitly call join() or get().

Using Custom Executors

By default, CompletableFuture uses the common ForkJoinPool; however, for finer-grained control over performance, you can provide your own Executor. This is particularly useful for CPU-intensive tasks or in cases where it is necessary to limit the number of concurrently executing threads.

Example:

ExecutorService executor = Executors.newFixedThreadPool(3);
CompletableFuture future = CompletableFuture.supplyAsync(() -> {
return 100;
}, executor);

Thus, the asynchronous operation gets executed by a special thread pool rather than the common one, which means a greater degree of control over resource management.

Chaining Asynchronous Operations

Perhaps the most powerful feature of CompletableFuture is the ability to sequentially chain asynchronous operations. You no longer need to write deeply nested callbacks, as you can orchestrate the execution of multiple tasks in such a way that the next task launches automatically as soon as the one it depends on completes.

Using thenApply()

The thenApply() method allows you to transform the result of a completed task. It takes the output of one task and applies a function to it, returning a new CompletableFuture with the transformed result.

Example:

CompletableFuture future = CompletableFuture.supplyAsync(() -> 10)
.thenApply(result -> result * 2);
System.out.println(future.join()); // Output: 20

Here, the multiplication happens only after the initial task completes.

Using thenCompose()

thenCompose() is used when you want to run another asynchronous task that depends on the previous task’s result. It flattens nested futures into a single CompletableFuture.

Example:

CompletableFuture future = CompletableFuture.supplyAsync(() -> 10)
.thenCompose(result -> CompletableFuture.supplyAsync(() -> result * 3));
System.out.println(future.join()); // Output: 30

This is ideal for tasks that need results from previous computations, such as fetching data from multiple APIs in sequence.

Using thenAccept()

If you only want to consume the result of a task without returning a new value, use thenAccept(). This is often used for side effects like logging or updating a UI.

Example:

CompletableFuture.supplyAsync(() -> "Hello")
.thenAccept(message -> System.out.println("Message: " + message));

The output will be:

Message: Hello

Combining Multiple CompletableFutures

In real-world applications, you often need to run multiple asynchronous tasks at the same time and then combine their results. For example, you might fetch data from several APIs or services in parallel and merge the results into a single response.

Running Tasks in Parallel

CompletableFuture provides several methods to make this process simple and efficient.

Running Tasks in Parallel

The allOf() method allows you to wait for all asynchronous tasks to complete before continuing.

Example:

CompletableFuture allTasks = CompletableFuture.allOf(
future1, future2, future3
);
allTasks.join(); // Waits for all tasks to finish

This approach is reasonable when you need all results before proceeding, such as aggregating data from multiple sources.

In practice, this method allows us to achieve the most significant benefits of asynchronous programming: in addition to increasing throughput, we also shorten the processing path for each request.

Waiting for the First Result with anyOf()

The anyOf() method completes as soon as one of the tasks finishes.

Example:

CompletableFuture<Object> firstCompleted =
CompletableFuture.anyOf(future1, future2);
Object result = firstCompleted.join();

This method is helpful when you only need the fastest response, such as querying multiple services and using whichever responds first.

Note: Don’t forget to cancel other futures if you don’t need their results. You need to give them the opportunity to cancel database queries, close sockets with other services, and, of course, cancel events in external queues of third-party services.

Combining Results with thenCombine()

When you have two independent tasks and want to merge their results, you can use thenCombine().

Example:

CompletableFuture combined =
future1.thenCombine(future2, (a, b) -> a + b);
System.out.println(combined.join());

Such an approach allows both tasks to run in parallel and combine their results when both are complete.

Exception Handling in Asynchronous Code

Managing errors in asynchronous programming is essential because exceptions don’t behave the same way as in synchronous code.

Instead of being thrown immediately, errors occur inside asynchronous tasks and must be handled explicitly using the built-in methods provided by CompletableFuture.

Using exceptionally()

The exceptionally() method is used to handle errors and provide a fallback result if something goes wrong.

Example:

CompletableFuture<Integer> future =

CompletableFuture.supplyAsync(() -> 10 / 0)

.exceptionally(ex -> {

System.out.println("Error occurred: " + ex.getMessage());

return 0; // fallback value

});

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

If an exception occurs, the method catches it and returns a default value instead of failing.

Using handle()

The handle() method allows you to process both success and failure cases in one place.

Example:

CompletableFuture future =
CompletableFuture.supplyAsync(() -> 10)
.handle((result, ex) -> {
if (ex != null) {
return 0;
}
return result * 2;
});
System.out.println(future.join());

Such a method suits when you want full control over the outcome, regardless of whether the task succeeds or fails.

Using whenComplete()

The whenComplete() method is used to perform an action after the task completes, whether it succeeds or fails, without changing the result.

Example:

CompletableFuture future =
CompletableFuture.supplyAsync(() -> 10)
.whenComplete((result, ex) -> {
if (ex != null) {
System.out.println("Error occurred");
} else {
System.out.println("Result: " + result);
}
});

This approach is often used for logging or cleanup tasks.

Best Practices for Asynchronous Programming in Java

If you want to achieve the full potential of asynchronous programming in Java, you can stick to certain best practices. In complex projects, many teams even consider Java developers for hire to guarantee these patterns are implemented correctly.

CompletableFuture is a helpful tool for asynchronous programming. However, improper use of it can result in performance issues and difficult-to-maintain code.

The very first rule is don’t block calls. Methods such as get() or a long operation inside asynchronous tasks can block threads and thus minimize the advantages of asynchronous execution.

In this case, you should rather use non-blocking methods, e. g. thenApply() or thenCompose() to maintain a steady flow.

Another thing to focus on is choosing the appropriate thread pool. The default common pool might not be a good fit, for instance, for large or very specific workloads.

At the same time, making custom executors will not only give you better control over how the tasks are executed but will also help you avoid resource contention.

The last tip is handling exceptions properly. Since errors in async code don’t behave like regular exceptions, you should always rely on methods like exceptionally() or handle() to get through failures and prevent silent errors.

Author Bio
Victor Krapivin Head of System Solutions Department
Victor Krapivin is a seasoned software engineer and product lead with a strong background in developing practical tools for developers and tech teams.

Looking for a Custom Fix?

SCAND’s the company to call for smart solutions and easy-going consulting.

Shoot us a message
and let's get started!
Contact us
Need Mobile Developers?

At SCAND you can hire mobile app developers with exceptional experience in native, hybrid, and cross-platform app development.

Mobile Developers Mobile Developers
Looking for Java Developers?

SCAND has a team of 50+ Java software engineers to choose from.

Java Developers Java Developers
Looking for Skilled .NET Developers?

At SCAND, we have a pool of .NET software developers to choose from.

NET developers NET developers
Need to Hire Professional Web Developers Fast and Easy?

Need to Hire Professional Web Developers Fast and Easy?

Web Developers Web Developers
Need to Staff Your Team With React Developers?

Our team of 25+ React engineers is here at your disposal.

React Developers React Developers
Searching for Remote Front-end Developers?

SCAND is here for you to offer a pool of 70+ front end engineers to choose from.

Front-end Developers Front-end Developers
Other Posts in This Category
View All Posts

This site uses technical cookies and allows the sending of 'third-party' cookies. By continuing to browse, you accept the use of cookies. For more information, see our Privacy Policy.