Asynchronous Programming in Spring Boot with CompletableFuture

java dev.to

Published 2026-06-11 by Shubham Bhati — Backend Engineer (Java 17, Spring Boot, Microservices).

We've all been there - stuck with a slow-performing API endpoint that's causing our application to grind to a halt. In our production environment, we recently encountered an issue where a single endpoint was taking up to 800ms to respond, causing our p99 latency to skyrocket. After some digging, we discovered that the culprit was a synchronous call to an external service. By switching to asynchronous programming using Spring Boot and CompletableFuture, we were able to reduce our p99 latency from 800ms to 120ms. This experience taught us the importance of using spring boot async CompletableFuture to write non-blocking code.

Introduction to Asynchronous Programming

Asynchronous programming is a technique that allows our application to perform multiple tasks concurrently, improving overall performance and responsiveness. In Java, we can use the java.util.concurrent package to write asynchronous code. Spring Boot provides additional support for asynchronous programming through the @Async annotation and CompletableFuture. By using these tools, we can write non-blocking code that's easier to maintain and scales better.

Using the @Async Annotation

The @Async annotation is a convenient way to mark a method as asynchronous. When we annotate a method with @Async, Spring Boot will automatically execute it in a separate thread. Here's an example:

@Service
public class MyService {

    @Async
    public CompletableFuture<String> doSomethingAsync() {
        // Simulate some long-running operation
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return CompletableFuture.completedFuture("Done");
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the doSomethingAsync method is annotated with @Async, indicating that it should be executed asynchronously.

Working with CompletableFuture

CompletableFuture is a powerful tool for working with asynchronous operations. It provides a flexible way to compose and combine multiple asynchronous tasks. Here's an example:

public CompletableFuture<String> doSomethingAsync() {
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
        // Simulate some long-running operation
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "Done";
    });
    return future;
}
Enter fullscreen mode Exit fullscreen mode

In this example, we use CompletableFuture.supplyAsync to create a new CompletableFuture instance that represents an asynchronous operation.

Configuring Thread Pools

When working with asynchronous programming, it's essential to configure thread pools properly. By default, Spring Boot uses a single thread pool with a fixed size of 10 threads. However, we can customize this behavior by creating a custom thread pool configuration. Here's an example:

@Configuration
public class ThreadPoolConfig {

    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("my-thread-");
        executor.initialize();
        return executor;
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, we create a custom thread pool configuration with a core pool size of 10 threads and a maximum pool size of 20 threads.

Handling Errors and Exceptions

When working with asynchronous programming, it's crucial to handle errors and exceptions properly. We can use try-catch blocks to catch and handle exceptions, but we can also use CompletableFuture.exceptionally to handle exceptions in a more elegant way. Here's an example:

public CompletableFuture<String> doSomethingAsync() {
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
        // Simulate some long-running operation
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "Done";
    });
    return future.exceptionally(ex -> {
        // Handle the exception
        return "Error";
    });
}
Enter fullscreen mode Exit fullscreen mode

In this example, we use CompletableFuture.exceptionally to handle exceptions in a more elegant way.

Handling Cancellation

When working with asynchronous programming, it's essential to handle cancellation properly. We can use CompletableFuture.cancel to cancel an ongoing asynchronous operation. Here's an example:

public CompletableFuture<String> doSomethingAsync() {
    CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
        // Simulate some long-running operation
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "Done";
    });
    // Cancel the operation
    future.cancel(true);
    return future;
}
Enter fullscreen mode Exit fullscreen mode

In this example, we use CompletableFuture.cancel to cancel an ongoing asynchronous operation.

Common Mistakes

Here are some common mistakes to avoid when working with asynchronous programming:

  • Not using @Async annotation correctly
  • Not configuring thread pools properly
  • Not handling errors and exceptions correctly
  • Not using CompletableFuture correctly
  • Not testing asynchronous code thoroughly

FAQ

What is the difference between @Async and CompletableFuture?

@Async is an annotation that marks a method as asynchronous, while CompletableFuture is a class that represents an asynchronous operation. We can use @Async to mark a method as asynchronous, and then use CompletableFuture to work with the resulting asynchronous operation.

How do I configure thread pools in Spring Boot?

We can configure thread pools in Spring Boot by creating a custom thread pool configuration using the ThreadPoolTaskExecutor class. We can also use the @Bean annotation to define a custom thread pool configuration.

What is the best way to handle errors and exceptions in asynchronous programming?

The best way to handle errors and exceptions in asynchronous programming is to use try-catch blocks to catch and handle exceptions, and then use CompletableFuture.exceptionally to handle exceptions in a more elegant way.

How do I test asynchronous code?

We can test asynchronous code by using testing frameworks such as JUnit or TestNG, and then using mock objects to simulate asynchronous operations. We can also use tools such as Spring Boot Test to test asynchronous code.

Conclusion

In conclusion, asynchronous programming is a powerful technique that can improve the performance and responsiveness of our application. By using spring boot async CompletableFuture, we can write non-blocking code that's easier to maintain and scales better. We can also use @Async annotation and CompletableFuture to work with asynchronous operations, and then configure thread pools and handle errors and exceptions correctly. For more information, we can refer to the Spring Boot documentation or the Java documentation.


Further Reading


Written by **Shubham Bhati* — Backend Engineer at AlignBits LLC, specializing in Java 17, Spring Boot, microservices, and AI integration. Connect on LinkedIn, GitHub, or read more at shubh2-0.github.io.*

Source: dev.to

arrow_back Back to Tutorials