Java
CompletableFuture
Spring Async annotation
Concurrency
Multithreading

CompletableFuture vs Async

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

CompletableFuture and Spring @Async are often compared as if they solve the same problem, but they operate at different layers. CompletableFuture is a Java API for composing asynchronous computations, while @Async is a Spring feature for dispatching a method invocation onto an executor.

What CompletableFuture Actually Does

CompletableFuture gives you a fluent way to chain, combine, transform, and recover from asynchronous work. It is part of the JDK, not Spring, and it is useful even in plain Java applications.

java
1import java.util.concurrent.CompletableFuture;
2
3public class FutureExample {
4    public static void main(String[] args) {
5        CompletableFuture<String> future = CompletableFuture
6            .supplyAsync(() -> "hello")
7            .thenApply(value -> value + " world")
8            .exceptionally(ex -> "fallback");
9
10        System.out.println(future.join());
11    }
12}

The important part is composition. You can express "run this, then transform the result, then combine it with another future, then handle errors" without blocking the current thread.

What Spring @Async Actually Does

@Async tells Spring to execute a method on a task executor through a proxy. It is about where and how the method runs, not about how later computations are composed.

java
1import org.springframework.scheduling.annotation.Async;
2import org.springframework.stereotype.Service;
3
4import java.util.concurrent.CompletableFuture;
5
6@Service
7public class ReportService {
8    @Async("ioExecutor")
9    public CompletableFuture<String> buildReport() {
10        return CompletableFuture.completedFuture("report-ready");
11    }
12}

If the call goes through the Spring proxy, the method runs asynchronously on the configured executor.

The Practical Difference

The cleanest mental model is:

  • '@Async chooses an execution boundary'
  • 'CompletableFuture models the async result and its downstream workflow'

That means they are complementary, not mutually exclusive. A Spring service method can be annotated with @Async and still return a CompletableFuture that the caller later combines with other futures.

Using Them Together

This is a common pattern in service aggregation:

java
1import org.springframework.stereotype.Service;
2
3import java.util.concurrent.CompletableFuture;
4
5@Service
6public class DashboardService {
7    private final UserService userService;
8    private final OrderService orderService;
9
10    public DashboardService(UserService userService, OrderService orderService) {
11        this.userService = userService;
12        this.orderService = orderService;
13    }
14
15    public CompletableFuture<String> loadDashboard(long userId) {
16        CompletableFuture<String> userFuture = userService.fetchUser(userId);
17        CompletableFuture<Integer> ordersFuture = orderService.fetchOrderCount(userId);
18
19        return userFuture.thenCombine(
20            ordersFuture,
21            (user, orderCount) -> user + " orders=" + orderCount
22        );
23    }
24}

Here the service methods may use @Async, but the orchestration is done with CompletableFuture.

Executor Choice Matters

Using either tool badly often comes down to executor configuration. The default executor may be fine for demos, but production code usually needs named pools sized for the workload.

java
1import org.springframework.context.annotation.Bean;
2import org.springframework.context.annotation.Configuration;
3import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
4
5import java.util.concurrent.Executor;
6
7@Configuration
8public class AsyncConfig {
9    @Bean(name = "ioExecutor")
10    public Executor ioExecutor() {
11        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
12        executor.setCorePoolSize(8);
13        executor.setMaxPoolSize(32);
14        executor.setQueueCapacity(200);
15        executor.setThreadNamePrefix("io-");
16        executor.initialize();
17        return executor;
18    }
19}

If your async work blocks on databases or remote services, executor tuning affects latency more than the API choice itself.

A Spring-Specific Gotcha

@Async works through Spring proxies, so self-invocation does not trigger asynchronous execution. If one method in a bean calls another @Async method on the same bean directly, the second call is just a normal method call.

That surprises many developers:

  • External bean call through proxy: async
  • Same-class direct call: not async

CompletableFuture does not have that proxy limitation because it is just Java code you create explicitly.

Common Pitfalls

The biggest mistake is treating @Async as a replacement for CompletableFuture. It is not. It does not provide chaining, combination, or structured error handling by itself.

Another issue is calling .join() immediately after creating a future. That turns asynchronous code back into a blocking call and often defeats the whole point.

Developers also forget about executor saturation. A perfectly correct CompletableFuture pipeline can still perform badly if all tasks are competing for an undersized shared pool.

Finally, be careful with @Async self-invocation. If the method call does not pass through a Spring proxy, no async dispatch happens.

Summary

  • 'CompletableFuture is a JDK API for composing asynchronous workflows.'
  • '@Async is a Spring mechanism for executing a method on an executor.'
  • They are complementary and are often best used together.
  • Configure executors deliberately for the type of work being performed.
  • Watch for Spring proxy behavior, especially self-invocation of @Async methods.

Course illustration
Course illustration

All Rights Reserved.